Server Actions under the hood
Follow a Next.js 16 Server Action from the click to the answer: the action id, the POST and its body, action-handler.ts, encrypted closures and redirects.
- 01Read an action's requestDecode an action id's first byte, and say why a call sends plain text or FormData, with which headers.
- 02Follow it through the serverName what action-handler.ts checks, which function reads the body, and how the action runs inside the request.
- 03Explain the answerSay what the encrypted bound values hold, when the answer carries the new UI, and how a redirect travels with and without JavaScript.
One click, one POST, one answer
Server Actions showed the outside: you write a function with "use server", call it from a form or a click, and it runs on the server. 'use client' and 'use server' under the hood showed what the compiler makes of it: a stub in the browser and an id the server can look up. What happens between the click and the answer is code in Next.js 16.4.3 that you can read: the router's request, action-handler.ts on the server, and the answer it sends.
The app is the tea shop's Mint tea page with a cart. It's a real Next.js project, built with next build, run with next start, and clicked in real Chrome. Click the files to read them:
app/actions.ts"use server"; import { cookies } from "next/headers"; const teas = ["mint", "jasmine", "oolong"]; export async function addToCart(formData: FormData) { const tea = formData.get("tea"); if (typeof tea !== "string" || !teas.includes(tea)) return; const jar = await cookies(); const count = Number(jar.get("cart")?.value ?? "0"); jar.set("cart", String(count + 1));} export async function emptyCart() { (await cookies()).set("cart", "0");} export async function rateTea(tea: string, stars: number) { if (!teas.includes(tea) || ![1, 2, 3, 4, 5].includes(stars)) return { saved: false }; // A stand-in for saving the rating: this example only logs it. console.log(`rateTea: ${stars} stars for ${tea}`); return { saved: true, stars };}Each step was taken in real Chrome against next start. Pick one to see the page after it.
Tins in your cart: 1
- Focus
- the button “Add to cart”
- Scrolled
- 0 px down
Requests the page made during this step (1), without the files it loaded
POST/200text/x-component- next-router-state-tree
- %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C82%5D
- next-action
- 408ea8cd1822508f04521eb1e08ac7463417a9a30d
The body sent, as
multipart/form-data; boundary=<boundary>--<boundary> Content-Disposition: form-data; name="_1_$ACTION_ID_408ea8cd1822508f04521eb1e08ac7463417a9a30d" --<boundary> Content-Disposition: form-data; name="_1_tea" mint --<boundary> Content-Disposition: form-data; name="0" ["$K1"] --<boundary>--- x-action-revalidated
- 1
The body that came back
2:"$Sreact.fragment" 3:"/_next/static/chunks/0blcza289dr69.js" 4:I[39756,["$3"],"default"] 5:I[37457,["$3"],"default"] 8:"$Sreact.suspense" b:"/_next/static/chunks/2865z4qszg65c.js" c:I[79696,["$3","$b"],"default"] d:I[97367,["$3"],"OutletBoundary"] f:"ViewportBoundary" 10:I[97367,["$3"],"$f"] 12:"MetadataBoundary" 13:I[97367,["$3"],"$12"] 7:{"id":"408ea8cd1822508f04521eb1e08ac7463417a9a30d","bound":null} a:{"id":"00bbb4cadc42aa8913ce46a1a18a52a7cc80a1b147","bound":null} 6:[["children",{"s":"__PAGE__","h":162,"d":{"r":["$","$2","c",{"children":[["$","main",null,{"children":[["$","h1",null,{"children":"Mint tea"}],["$","form",null,{"action":"$h7","children":[["$","input",null,{"type":"hidden","name":"tea","value":"mint"}],["$","button",null,{"children":"Add to cart"}]]}],["$","$8",null,{"fallback":["$","p",null,{"children":"Opening your cart…"}],"children":"$L9"}],["$","form",null,{"action":"$ha","children":["$","button",null,{"children":"Empty cart"}]}],["$","$Lc",null,{}]]}],[["$","script","script-0",{"src":"/_next/static/chunks/2865z4qszg65c.js","async":true,"nonce":"$undefined"}]],["$","$Ld",null,{"children":["$","$8",null,{"name":"Next.MetadataOutlet","children":"$@e"}]}]]}],"p":false,"v":null}}]] 0:{"a":"$@1","q":"","i":false,"t":{"t":{"s":"","h":82,"d":{"r":["$","$2","c",{"children":[[["$","script","script-0",{"src":"/_next/static/chunks/0blcza289dr69.js","async":true,"nonce":"$undefined"}]],["$","html",null,{"lang":"en","children":["$","body",null,{"children":["$","$L4",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L5",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]]}],"p":false,"v":null},"c":"$Q6"},"h":{"r":["$","$2","h",{"children":[null,["$","$L10","U_r46BJ2eURkUWKVWZ9Lrv",{"children":"$L11"}],["$","$L13","U_r46BJ2eURkUWKVWZ9Lrm",{"children":[["$","div",null,{"hidden":true,"children":["$","$8",null,{"name":"Next.Metadata","children":"$L14"}]}],null]}]]}],"p":false,"v":null}},"b":"<buildId>"} 1:"$undefined" 9:["$","p",null,{"children":["Tins in your cart: ","1"]}] 11:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] e:null 14:[["$","title","0",{"children":"Tea shop"}]]
app/actions.ts, shown first, holds three actions. Lines 7 to 13 are addToCart: line 8 reads the tea from the form, line 9 refuses anything that isn't a tea on the list, and line 12 adds one to the cart cookie. emptyCart sets it back to 0, and rateTea takes a tea and a number of stars, logs them and returns a result. The cart is a cookie in the visitor's own browser, with no account behind it, so there is nobody to check; each action still checks its input. rateTea is a stand-in: a real shop would check who is rating and save it.
In app/page.tsx, line 10 gives the first form action={addToCart}, and line 11 adds a hidden field, tea. Lines 14 to 16 show the cart from the cookie inside <Suspense>, because the cookie is only known when a request comes in. app/rate-button.tsx is a Client Component whose button calls rateTea("mint", 5).
The Steps tab is open on the click on Add to cart. It made one request: a fetch, POST /, answered 200 with text/x-component. Under it are its headers, the body it sent and the body that came back; the recorder kept both. Step 3 is the click on Rate 5 stars, and the Terminal tab shows what next start printed for it, rateTea: 5 stars for mint.
A few words first. The answer is an RSC payload: the text React writes for what Server Components rendered, as numbered lines called rows. Flight is React's name for that format. It also works the other way: React's encodeReply writes a function's arguments in it in the browser, and decodeReply reads them back on the server. The server reference manifest, server-reference-manifest.json, is a lookup the build makes from each action id to the server module and export that hold the function.
POST to the page's own address. The server runs the action, renders the page again because a cookie changed, and sends both back in one RSC payload. The id is this build's.You order from a catalogue by filling in a form: a number for what you want, and boxes for the details. The clerk checks that the form is the shop's own and not too heavy, looks the number up, does the work, and posts back one parcel with your receipt and, if prices changed, the new price list.
- In real life: The catalogue number you write on the form
- In Next.js: The action's id, in the next-action header
- In real life: The boxes you fill in
- In Next.js: The arguments, written by encodeReply into the body
- In real life: The clerk checking the form came from this shop and isn't too heavy
- In Next.js: The Origin check and the 1 MB body limit
- In real life: The catalogue index the clerk looks the number up in
- In Next.js: The server reference manifest
- In real life: One parcel back, with your receipt and the updated price list
- In Next.js: One answer: the result in a, the new UI in t
A Server Action call is one POST to the page's own address, with the action's id in next-action and the arguments written by encodeReply. action-handler.ts checks the request, reads the arguments, runs the function inside the request, and answers with one RSC payload: the result, and the new UI when the action changed something.
How an action id is made
The references lesson found the id in the build: SWC's server_actions.rs transform makes it in generate_server_reference_id (lines 284 to 372), as one byte followed by a SHA-1 hash, a short fingerprint, of three things: a secret, the file's path and the export's name. Two parts of that are new here: what the secret is, and what the first byte is for.
The secret is the build's encryption key, the same key that encrypts a closure's values: Turbopack passes it to the transform as hash_salt (next_shared/transforms/server_actions.rs, line 87). next build takes it from NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, or makes a random 256-bit key (encryption-utils-server.ts, lines 97 to 129). Either way the key is stored in .next/cache/.rscinfo with an expiry date 14 days on (lines 11 to 94), and a later build reuses a stored key that hasn't expired and, when the variable is set, matches it. Without persistent storage, which Next.js assumes inside Docker (cache-dir.ts), nothing is stored, so a generated key is new on every build; a key set in the variable stays the same.
Both examples set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY in their .env file to a fixed key. That's a recording fixture, not a security measure: it keeps the ids the same on every build, and makes the recorded encrypted value readable to anyone who has the key. A real app uses a secret random key (openssl rand -base64 32), the same on every server that runs one build, and never writes it in a file it shares.
With the fixed key, anyone can work the ids out again: the SHA-1 of the key, app/actions.ts, a colon and addToCart is the last 40 characters of 408ea8cd1822508f04521eb1e08ac7463417a9a30d. Each of the four recorded ids is made the same way.
addToCart's id, in two parts. The first byte is about the arguments; the other 20 bytes are a SHA-1 hash of the build's key, the file and the export's name. With another key, the hash changes.The first byte is laid out by a comment in the transform, and added at the end:
// Prepend an extra byte to the ID, with the following format:
// 0 000000 0
// ^type ^arg mask ^rest args
//
// The type bit represents if the action is a cache function or not.
// For cache functions, the type bit is set to 1. Otherwise, it's 0.
//
// The arg mask bit is used to determine which arguments are used by
// the function itself, up to 6 arguments. The bit is set to 1 if the
// argument is used, or being spread or destructured (so it can be
// indirectly or partially used). The bit is set to 0 otherwise.
//
// The rest args bit is used to determine if there's a ...rest argument
// in the function signature. If there is, the bit is set to 1.
//
// …
result.push((type_bit << 7) | (arg_mask << 1) | rest_args);
result.rotate_right(1);- Lines 2 and 3 draw the 8 bits: one type bit, six argument bits, one rest bit.
- Lines 5 and 6: the type bit is 1 for a
"use cache"function and 0 for a Server Action. - Lines 8 to 11: one bit for each of the first six parameters. The code just above (lines 339 to 366) sets a bit for every parameter the function declares, used or not, as its comment admits.
- Lines 13 and 14: the last bit says whether there's a
...restparameter, or more than six. - Line 17 adds the byte after the 20 bytes of the hash, and line 18 rotates the list one step, so the last byte comes first. Each byte is then written as two hexadecimal digits.
Who reads it? The browser. Before it sends anything, the router passes the arguments through omitUnusedArgs:
export function omitUnusedArgs(
args: unknown[],
info: ServerReferenceInfo
): unknown[] {
const filteredArgs = new Array(args.length)
let length = 0
for (let index = 0; index < args.length; index++) {
if (
(index < 6 && info.usedArgs[index]) ||
// This assumes that the server reference info byte has the restArgs bit
// set to 1 if there are more than 6 args.
(index >= 6 && info.hasRestArgs)
) {
filteredArgs[index] = args[index]
length = index + 1
}
}
// Trim trailing unused args from the array.
filteredArgs.length = length
return filteredArgs
}Lines 9 to 17 keep an argument only if its bit is 1 (the first six) or the rest bit is 1 (the ones after). Line 21 cuts off the unused ones at the end. extractInfoFromServerReferenceId, in the same file (lines 33 to 60), reads the bits from the first two characters of the id. Pick each recorded action and compare its bits with what its click sent:
function: emptyCart()
id: 00bbb4cadc42aa8913ce46a1a18a52a7cc80a1b147
first byte: 00 = 0 000000 0
type bit: 0 (a Server Action)
arguments: 0 0 0 0 0 0 (uses none)
rest bit: 0 (no ...rest parameter)
sent: text/plain, arguments []No parameters, so no argument bits are set. The click sent []: an empty list of arguments.
emptyCart is called by a form, and a form action always gets the form's FormData. Its byte is 00, so the FormData was dropped and the body was []. saveForLater, from the second example, is stranger: you wrote it with no parameters, yet its byte is 40. The compiler gave the function it moves out of the page one parameter, for the encrypted values of its closure. The form's FormData came second and was dropped too.
The id's layout, the headers, the field names and the row shapes shown here are Next.js 16.4.3's internals, from these recorded builds. They can change in any release. What's promised is what the docs describe: actions are called with a POST, return their result, can redirect and revalidate, and work in forms before JavaScript loads. Never read ids or these headers from your app's code.
The Next-Action request
A Client Component that calls rateTea really calls the stub from createServerReference, which hands the id and the arguments to Next.js's callServer. That queues the call, so actions run one at a time (Server Actions showed it), and the router sends it in fetchServerAction:
const usedArgs = omitUnusedArgs(actionArgs, info)
const body = await encodeReply(usedArgs, { temporaryReferences })
const headers: Record<string, string> = {
Accept: RSC_CONTENT_TYPE_HEADER,
[ACTION_HEADER]: actionId,
[NEXT_ROUTER_STATE_TREE_HEADER]: prepareFlightRouterStateForRequest(
state.tree
),
}
// …
res = await fetch(state.canonicalUrl, { method: 'POST', headers, body })- Line 1 drops the unused arguments, as above.
- Line 2 writes the rest with React's
encodeReply. - Lines 4 to 10 are the headers:
Accept: text/x-component, the type of answer it wants;next-action, the id; andnext-router-state-tree, the router's description of what's on the screen. - Line 12 sends a
POSTtostate.canonicalUrl, the address of the page you're on. There is no address of the action's own.
(Lines 116 to 125 and line 151.) No line sets a Content-Type. fetch picks it from the body: a string goes as text/plain;charset=UTF-8, a FormData as multipart/form-data with a boundary, a line that separates its parts. Which one encodeReply returns is decided at the end of React's processReply, in ReactFlightReplyClient.js, lines 1008 to 1020. Next.js 16.4.3 runs its own build of React, 19.3.0-canary-278794d7-20261002, from next/dist/compiled, so React's lines here are cited at the commit that build was made from. React 19.3.1, the release the React lessons cite, is a reference only; its line numbers differ:
const json = serializeModel(root, 0);
if (formData === null) {
// If it's a simple data structure, we just use plain JSON.
resolve(json);
} else {
// Otherwise, we use FormData to let us stream in the result.
formData.set(formFieldPrefix + '0', json);
if (pendingParts === 0) {
// $FlowFixMe[incompatible-type] this has already been refined.
resolve(formData);
}
}Line 1 writes the whole list of arguments as JSON, with markers like $K1 for values JSON can't hold. Lines 3 to 5: if nothing on the way needed FormData, the body is that text. Lines 6 to 12: otherwise it's a FormData, with the JSON as part 0. A FormData argument needs one (lines 687 to 705), and so does a promise, which arrives later as a part of its own. Each row is one recorded call; the Steps tab of each example has the bodies:
| Call | Content type | What the body holds |
|---|---|---|
rateTea("mint", 5), from a click | text/plain;charset=UTF-8 | ["mint",5]: the arguments as one line of JSON. |
emptyCart(), from a form | text/plain;charset=UTF-8 | []: no arguments. The form's FormData was left out, because the id's first byte says the function uses none. |
addToCart(formData), from a form | multipart/form-data | Part 0 is ["$K1"], a pointer to a FormData; the form's own fields follow, each name starting with _1_. |
saveForLater, from a form | multipart/form-data | Part 0 is ["$@1"], a pointer to part 1, which holds the encrypted values. No form fields went. |
This is the body of the click on Add to cart, as Chrome sent it, with the boundary Chrome made up shown as a placeholder:
--<boundary>
Content-Disposition: form-data; name="_1_$ACTION_ID_408ea8cd1822508f04521eb1e08ac7463417a9a30d"
--<boundary>
Content-Disposition: form-data; name="_1_tea"
mint
--<boundary>
Content-Disposition: form-data; name="0"
["$K1"]
--<boundary>--
The last part, 0, is the list of arguments: ["$K1"], "the FormData number 1". Its fields come first, each name starting with _1_: tea with mint, and a field named $ACTION_ID_ plus the id, which React put in the form for visitors without JavaScript. The next-router-state-tree header is encoded for the address bar's rules; decoded, it reads:
["",{"children":["__PAGE__",{},null,null,162]},null,null,82]The empty string is the root layout's segment, and __PAGE__ the page inside it. The server uses it to render the new UI for exactly that screen; the numbers and the other fields belong to the client router, which this stage reads in The client router.
action-handler.ts: the server's side
From next dev to a response followed a request to the page's render, and How a route becomes a React tree into app-render.tsx. Before rendering, app-render.tsx asks whether the request may be an action, and if so calls handleAction (app-render.tsx, lines 3066 to 3124). The question is answered here:
const isURLEncodedAction = Boolean(
req.method === 'POST' && contentType === 'application/x-www-form-urlencoded'
)
const isMultipartAction = Boolean(
req.method === 'POST' && contentType?.startsWith('multipart/form-data')
)
const isFetchAction = Boolean(
actionId !== undefined &&
typeof actionId === 'string' &&
req.method === 'POST'
)
const isPossibleServerAction = Boolean(
isFetchAction || isURLEncodedAction || isMultipartAction
)Lines 7 to 11: a POST with a next-action header is a fetch action, one the router sent. Lines 4 to 6: any multipart POST might be one too, a form posted before JavaScript, so it's let through to be looked at. Lines 1 to 3 let urlencoded forms through only so that handleAction can turn them away at once (action-handler.ts, lines 680 to 691).
Then handleAction (lines 616 to 1453) checks the request before it reads a single byte of the body. The Origin header must match the Host (or X-Forwarded-Host), unless serverActions.allowedOrigins lists it (lines 710 to 789). Securing Server Actions recorded each case. One of them matters for every action you write: a request with no Origin at all is let through, with only a warning (lines 731 to 736). A missing Origin proves nothing, and the check never says who is asking. Next, the answer is marked not to be cached (lines 796 to 799), and the size limit is worked out:
const defaultBodySizeLimit = '1 MB'
const bodySizeLimit =
serverActions?.bodySizeLimit ?? defaultBodySizeLimit
const bodySizeLimitBytes =
bodySizeLimit !== defaultBodySizeLimit
? (
require('next/dist/compiled/bytes') as typeof import('next/dist/compiled/bytes')
).parse(bodySizeLimit)
: 1024 * 1024 // 1 MBLine 1 is the default, 1 MB; lines 2 and 3 take serverActions.bodySizeLimit instead when you set one. While the body streams in, a counter adds up its bytes and stops at the limit with Body exceeded 1 MB limit. (lines 1058 to 1078). That error is made with status 413, but a fetch action's error branch answers 500 (lines 1415 to 1420, with a note that it may change), which is the status the securing lesson recorded.
Only then is the body read, by one of three React functions. Each row is one kind of body:
| What arrived | Who reads it | Where |
|---|---|---|
A multipart body with a next-action header | decodeReplyFromBusboy, which reads the parts as they stream in. | Lines 1081 to 1114. |
A text body with a next-action header | decodeReply, after the whole body has arrived. | Lines 1193 to 1234. |
A multipart body without next-action | decodeAction, once areAllActionIdsValid has checked every id in the fields. | Lines 1115 to 1192. |
A urlencoded form (application/x-www-form-urlencoded) | Nobody: Next.js doesn't treat it as an action. | Lines 680 to 691. |
All three get the server module map built from server-reference-manifest.json, so a reference inside the body, like a bound action, can be turned back into a function. For a fetch action, getActionModIdOrError (lines 1522 to 1546) finds the module for the id, and the code the references lesson quoted takes the export named after it. An id the manifest doesn't know gets a short plain-text answer, Server Action unavailable. with status 409, and the header x-nextjs-action-not-found: 1 (lines 99 to 107 and 649 to 671), the case of a page from an older deployment. Then the function runs:
requestStore.phase = 'action'
let skipPageRendering = shouldSkipPageRendering
if (args.length > SERVER_ACTION_ARGS_LIMIT) {
throw new Error(
`Server Action arguments list is too long (${args.length}). Maximum allowed is ${SERVER_ACTION_ARGS_LIMIT}.`
)
}
try {
const actionResult = await workUnitAsyncStorage.run(requestStore, () =>
action.apply(null, args)
)
// If the page was not revalidated, or if this is an action-only request,
// we can skip rendering the page.
skipPageRendering ||=
workStore.pathWasRevalidated === undefined ||
workStore.pathWasRevalidated === ActionDidNotRevalidate
return { actionResult, skipPageRendering }- Line 1 sets the request's phase to
"action". In this phase,cookies()returns cookies you can change. - Lines 4 to 8 refuse more than 1,000 arguments, a guard against huge requests.
- Lines 10 to 12 call your function inside
workUnitAsyncStorage.run(requestStore, …). The request store is this request's own data (its cookies, headers and draft mode), kept in Node.js'sAsyncLocalStorage, which lets every function called from here find it without being passed it. That's howcookies()on line 10 ofapp/actions.tsknows which visitor it's for. - Lines 17 to 19 decide whether the page must be rendered again: only if something was revalidated.
Afterwards, if the page will be rendered, the phase becomes "render", the changed cookies are copied where the render reads them, and any pending revalidation finishes first (lines 1494 to 1515).
Encrypting closures and bound arguments
Securing Server Actions showed that the values an inline action uses are encrypted into the page, and why that isn't a check of who is calling. Here is how it's done. The second example is the shop's tea pages, each with a Save for later button:
app/teas/[tea]/page.tsximport { cookies } from "next/headers";import { notFound, redirect } from "next/navigation"; const teas: Record<string, { name: string; price: number }> = { mint: { name: "Mint", price: 3 }, jasmine: { name: "Jasmine", price: 4 },}; export function generateStaticParams() { return [{ tea: "mint" }, { tea: "jasmine" }];} export default async function TeaPage({ params }: PageProps<"/teas/[tea]">) { const { tea } = await params; if (!(tea in teas)) notFound(); const { name, price } = teas[tea]; async function saveForLater() { "use server"; (await cookies()).set("saved", `${tea}:${price}`); redirect("/saved"); } return ( <main> <h1>{name} tea</h1> <p>${price} a tin.</p> <form action={saveForLater}> <button>Save for later</button> </form> </main> );}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 18 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1o6jvcf9smbzx.js">
<script src="/_next/static/chunks/0v8ccsftv8ajp.js" async=""></script>
<script src="/_next/static/chunks/0kxcdnce56bv1.js" async=""></script>
<script src="/_next/static/chunks/turbopack-1gh6_hegcwrtt.js" async=""></script>
<script src="/_next/static/chunks/30e5tuscv0mzh.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint<!-- --> tea</h1>
<p>$<!-- -->3<!-- --> a tin.</p>
<form action="" enctype="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_REF_0"><input type="hidden" name="$ACTION_0:2" value=""qbZzYOBcHhI4FRiXlssFQBLi8YTHNgxIl0ejZe2Qr6IilfKLvGQuI38M6QKB3+fFUKtt/NQpuzwRk2epXMg0bK5HICMpQCbXVY1i/bqz0VmHSm0RuZHo""><input type="hidden" name="$ACTION_0:1" value="["$@2"]"><input type="hidden" name="$ACTION_0:0" value="{"id":"400b17997f72c388752826c00e0606fb69cdd1f795","bound":"$@1"}"><button>Save for later</button></form>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1o6jvcf9smbzx.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In app/teas/[tea]/page.tsx, line 14 reads the tea from the address and line 16 looks up its price. Lines 18 to 22 are saveForLater, written inside the page component: it uses tea and price from around it, so it's a closure. Line 20 saves both in a cookie, and line 21 redirects. The visitor isn't signed in and the cookie is their own, so there's nothing to check here; an action that touched an account would check who is asking first. The page was prerendered for mint and jasmine during the build.
The action runs later, on another request, when the component's variables are long gone. So the values must travel with the page. With turbopackMinify: false in next.config.ts (only to make the build readable), the recording kept the page's compiled module. This part is the action and the line that hands it to the form, exactly as Turbopack wrote it:
const $$RSC_SERVER_ACTION_0 = async function saveForLater($$ACTION_CLOSURE_BOUND) {
var [$$ACTION_ARG_0, $$ACTION_ARG_1] = await (0, __TURBOPACK__imported__module__44721__["decryptActionBoundArgs"])("400b17997f72c388752826c00e0606fb69cdd1f795", $$ACTION_CLOSURE_BOUND);
(await (0, __TURBOPACK__imported__module__5246__["cookies"])()).set("saved", `${$$ACTION_ARG_0}:${$$ACTION_ARG_1}`);
(0, __TURBOPACK__imported__module__73727__1["redirect"])("/saved");
};
(0, __TURBOPACK__imported__module__37936__["registerServerReference"])($$RSC_SERVER_ACTION_0, "400b17997f72c388752826c00e0606fb69cdd1f795", null);
async function TeaPage({ params }) {
const { tea } = await params;
if (!(tea in teas)) (0, __TURBOPACK__imported__module__73727__1["notFound"])();
const { name, price } = teas[tea];
var saveForLater = $$RSC_SERVER_ACTION_0.bind(null, (0, __TURBOPACK__imported__module__44721__["encryptActionBoundArgs"])("400b17997f72c388752826c00e0606fb69cdd1f795", tea, price));- Line 1: the compiler moved the action out of the component, to the top of the module, as
$$RSC_SERVER_ACTION_0, with one parameter,$$ACTION_CLOSURE_BOUND. That parameter is the40in its id. - Line 2 is its new first line:
decryptActionBoundArgsturns that parameter back into two variables, which line 3 uses in place ofteaandprice. - Line 6 registers it as a server reference with its id, as in the references lesson.
- Line 11, inside
TeaPage:saveForLateris now the moved function with its first argument fixed by bind, and that argument isencryptActionBoundArgs("400b17…", tea, price).
The transform writes those two calls in server_actions.rs, lines 530 to 560 (the decryption line) and lines 3200 to 3232 (the bind). encryptActionBoundArgs (encryption.ts, lines 108 to 240) first writes the values as Flight text with React's renderToReadableStream (line 161), then encrypts that text:
async function encodeActionBoundArg(actionId: string, arg: string) {
const key = await getActionEncryptionKey()
if (key === undefined) {
throw new Error(
`Missing encryption key for Server Action. This is a bug in Next.js`
)
}
// Get 16 random bytes as iv.
const randomBytes = new Uint8Array(16)
workUnitAsyncStorage.exit(() => crypto.getRandomValues(randomBytes))
const ivValue = arrayBufferToString(randomBytes.buffer)
const encrypted = await encrypt(
key,
randomBytes,
textEncoder.encode(actionId + arg)
)
return btoa(ivValue + arrayBufferToString(encrypted))
}- Line 2 gets the key, from
getActionEncryptionKey. - Lines 9 to 12 make 16 random bytes, the IV (initialization vector): a starting value that makes the same text encrypt differently each time.
- Lines 14 to 18 encrypt the action's id followed by the Flight text, with AES-GCM, a standard cipher that also detects any change to what it encrypted.
encryptcalls the Web Crypto API'scrypto.subtle.encrypt(encryption-utils.ts, lines 37 to 50). - Line 20 joins the IV and the result and writes them as base64 text, safe to put in a page.
export async function getActionEncryptionKey() {
if (__next_loaded_action_key) {
return __next_loaded_action_key
}
const serverActionsManifest = getServerActionsManifest()
const rawKey =
process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY ||
serverActionsManifest.encryptionKey
if (rawKey === undefined) {
throw new InvariantError('Missing encryption key for Server Actions')
}
__next_loaded_action_key = await crypto.subtle.importKey(
'raw',
stringToUint8Array(atob(rawKey)),
'AES-GCM',
true,
['encrypt', 'decrypt']
)
return __next_loaded_action_key
}Lines 8 to 10: the environment variable if it's set, otherwise the key the build wrote into server-reference-manifest.json (the references lesson's recording showed it as a placeholder). Lines 16 to 22 turn it from base64 into a key the Web Crypto API can use. Every server that runs one build must have the same key, or one server can't read what another wrote.
In the Network tab above, the recorded HTML of /teas/mint has the result in four hidden fields of the form. These are they, as the HTML has them (only the last value changes from build to build, because of the IV):
$ACTION_REF_0 = (empty)
$ACTION_0:2 = "qbZzYOBcHhI4FRiXlssFQBLi8YTHNgxIl0ejZe2Qr6IilfKLvGQuI38M6QKB3+fFUKtt/NQpuzwRk2epXMg0bK5HICMpQCbXVY1i/bqz0VmHSm0RuZHo"
$ACTION_0:1 = ["$@2"]
$ACTION_0:0 = {"id":"400b17997f72c388752826c00e0606fb69cdd1f795","bound":"$@1"}$ACTION_0:2 is the encrypted text. The recording's key is fixed and known, so the value can be decrypted, which is exactly why a real key stays secret. Decrypted with it, the recorded value reads:
400b17997f72c388752826c00e0606fb69cdd1f7950:["mint",3]
The action's id, then one Flight row: row 0, the list ["mint",3], the values of tea and price. When the form comes back, the action's first line decrypts it:
async function decodeActionBoundArg(actionId: string, arg: string) {
const key = await getActionEncryptionKey()
if (typeof key === 'undefined') {
throw new Error(
`Missing encryption key for Server Action. This is a bug in Next.js`
)
}
// Get the iv (16 bytes) and the payload from the arg.
const originalPayload = atob(arg)
const ivValue = originalPayload.slice(0, 16)
const payload = originalPayload.slice(16)
const decrypted = textDecoder.decode(
await decrypt(key, stringToUint8Array(ivValue), stringToUint8Array(payload))
)
if (!decrypted.startsWith(actionId)) {
throw new Error('Invalid Server Action payload: failed to decrypt.')
}
return decrypted.slice(actionId.length)
}- Lines 10 to 12 undo the base64 and split off the 16 bytes of the IV.
- Lines 14 to 16 decrypt. AES-GCM refuses text that was changed, so a made-up value fails here; the securing lesson's recording got
500for one. - Lines 18 to 20 refuse text that doesn't start with this action's id. A value encrypted for one action can't be sent to another.
- Line 22 returns the Flight text, which
decryptActionBoundArgsreads back into values with React'screateFromReadableStream(lines 243 to 329).
Nothing here stops the same value from being sent again, by anyone who loaded the page, as often as they like. Encryption keeps the values from being read or changed; it doesn't say who is calling. Next.js's docs add that it shouldn't be trusted to hide a sensitive value either. Keep secrets out of closures, and check who is asking inside the action.
One round trip: the result and the new UI
Back in handleAction, a fetch action's result goes to generateFlight, which renders the RSC payload. Its first row is built here:
// If we have an action result, then this is a server action response.
// We can rely on this because `ActionResult` will always be a promise, even if
// the result is falsey.
if (options?.actionResult) {
const actionResponse: ActionFlightResponse = {
a: options.actionResult,
q: getRenderedSearch(query),
i: !!couldBeIntercepted,
}
if (transportData !== undefined) {
actionResponse.t = transportData
}Lines 5 to 9: row 0 always holds a, the action's result, with q, the page's search string, and i, whether an intercepting route could answer this address. Lines 10 to 12 add t, the new tree, only when the page was rendered. It isn't when skipPageRendering is true (line 623). This is all of rateTea's recorded answer:
0:{"a":"$@1","q":"","i":false,"b":"<buildId>"}
1:{"saved":true,"stars":5}Row 0 has a, q, i and b, the build's id, shown as a placeholder. No t. "$@1" means "a promise, resolved by row 1", and row 1 is what rateTea returned. The router hands it to the button's await (line 265 of server-action-reducer.ts), which shows Saved: 5 stars. The rest of the row format is covered in The RSC payload.
addToCart's answer was 21 rows, and its row 0 has the keys a, q, i, t, b: the result, and the page. Two of its rows, the new cart line and the form's action, which crosses as a reference:
9:["$","p",null,{"children":["Tins in your cart: ","1"]}]
7:{"id":"408ea8cd1822508f04521eb1e08ac7463417a9a30d","bound":null}Why the page this time? Line 12 set a cookie, and setting a cookie during an action marks the page as changed:
const updateResponseCookies = () => {
// TODO-APP: change method of getting workStore
const workStore = workAsyncStorage.getStore()
if (workStore) {
workStore.pathWasRevalidated = ActionDidRevalidateStaticAndDynamic
}Line 5 sets pathWasRevalidated, the flag that lines 17 to 19 of the earlier excerpt read, so skipPageRendering stays false. updateTag, revalidateTag, revalidatePath and refresh set it too (revalidate.ts, line 101 and line 262), as Updating the UI after a mutation showed from the outside. The answer also gets a header for the router:
const isCookieRevalidated = getModifiedCookieValues(
requestStore.mutableCookies
).length
? 1
: 0
// First check if a tag, cookie, or path was revalidated.
if (isTagRevalidated || isCookieRevalidated) {
res.setHeader(
NEXT_ACTION_REVALIDATED_HEADER,
JSON.stringify(ActionDidRevalidateStaticAndDynamic)
)Lines 1 to 5 check for changed cookies, and lines 8 to 12 then send x-action-revalidated: 1, as the recorded answers to addToCart and emptyCart have it (1 and 1). refresh() alone sends 2, for "only request-time data changed". The router reads it to drop what it had cached (lines 201 to 213). Each row is one recorded answer:
| Action | x-action-revalidated | Row 0's keys | Why |
|---|---|---|---|
rateTea | None | a, q, i, b | Nothing changed, so the page wasn't rendered again: the answer is only the result. |
addToCart | 1 | a, q, i, t, b | It set a cookie, so the page was rendered again, and t holds the new tree. |
emptyCart | 1 | a, q, i, t, b | The same: a cookie changed. |
Redirects from actions
Redirects in Next.js recorded both answers to a Server Action that calls redirect(): 200 with JavaScript, 303 without. saveForLater redirects to /saved, and this is what its click got back in real Chrome (the Steps tab of the second example has all of it):
status: 200
x-action-revalidated: 1
x-action-redirect: /saved;push
body: 68 rows, among them:
b:["$","p",null,{"children":["Saved for later: ","mint"," at $","3"," a tin"]}]A 200, two headers, and a body that is the /saved page: its row with Saved for later: mint at $3 a tin. The click made no other request. This is the branch that decides:
if (isRedirectError(err)) {
const redirectUrl = getURLFromRedirectError(err)
const redirectType = getRedirectTypeFromError(err)
if (isFetchAction) {
// Fetch actions communicate redirects through `x-action-redirect` and
// can include the redirect target's Flight response in the body. Since
// this is not an HTTP redirect, keep the response successful.
res.statusCode = 200
metadata.statusCode = 200
return {
type: 'done',
result: await createRedirectRenderResult(
req,
res,
host,
redirectUrl,
redirectType,
ctx.renderOpts.basePath,
workStore,
requestStore.url.pathname
),
}
}
// For an MPA action, the redirect doesn't need a body, just a Location header.
res.statusCode = RedirectStatusCode.SeeOther
metadata.statusCode = RedirectStatusCode.SeeOther
res.setHeader('Location', redirectUrl)
return {
type: 'done',
result: RenderResult.EMPTY,
}- Line 1:
redirect()works by throwing a special error, so the redirect is caught here, after the action stopped. - Lines 5 to 25, for a fetch action: status
200, andcreateRedirectRenderResult(lines 402 to 521). It setsx-action-redirectto the address and the type, joined by;(line 412). For an address in the same app, it asks the server itself for that page's RSC payload, with the request's cookies and the new ones, and sends that as the body (lines 426 to 518). It only tries: for an address outside the app, or when that fetch fails or doesn't come back as an RSC payload, the body is empty (line 520). For/savedit worked, so the recorded body is/saved's payload. - Lines 27 to 30, for a form posted without JavaScript:
303 See Otherand aLocationheader, and no body.
The router reads the header like this:
const redirectHeader = res.headers.get('x-action-redirect')
const [location, _redirectType] = redirectHeader?.split(';') || []
let redirectType: RedirectType | undefined
switch (_redirectType) {
case 'push':
redirectType = 'push'
break
case 'replace':
redirectType = 'replace'
break
default:
redirectType = undefined
}Line 2 splits /saved;push into the address and the type. push adds a history entry, so Back returns to the tea page, as the redirects lesson recorded. With the payload already in hand, the router shows /saved at once.
Without JavaScript, the second example's Network tab has the form posted the way a browser does before its JavaScript loads: 303, location: /saved, and set-cookie: saved=mint%3A3; Path=/. The recorder then asked for /saved with that cookie, as a browser follows a 303, and got Saved for later: mint at $3 a tin.
redirect("/saved"), two answers. The router can move by itself, so it gets a 200, here with the new page inside (Next.js tries that for an address in the same app). A plain form post needs a real HTTP redirect: 303, then a GET.Forms that post before JavaScript loads
A form whose action is a Server Action works before the page's JavaScript has loaded, and with it turned off. The browser can't add a next-action header, so React puts what the server needs into the form itself. These are the cart page's two forms in its recorded HTML:
<form action="" enctype="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_ID_408ea8cd1822508f04521eb1e08ac7463417a9a30d"><input type="hidden" name="tea" value="mint"><button>Add to cart</button></form>
<form action="" enctype="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_ID_00bbb4cadc42aa8913ce46a1a18a52a7cc80a1b147"><button>Empty cart</button></form>Each form posts to its own page (action=""), as multipart/form-data, with a hidden field whose name holds the id: $ACTION_ID_ plus addToCart's or emptyCart's id. React picks the names in defaultEncodeFormAction, ReactFlightReplyClient.js, lines 1073 to 1122:
if (boundPromise !== null) {
// …
// We encode the name of the prefix containing the data.
name = '$ACTION_REF_' + identifierPrefix;
} else {
// This is the simple case so we can just encode the ID.
name = '$ACTION_ID_' + referenceClosure.id;Line 1: an action with bound values, like saveForLater, gets $ACTION_REF_ plus a prefix (line 4), and its reference and values go in fields named $ACTION_, the prefix, a colon and a number. That's the tea page's $ACTION_0:0, the reference ({"id":"400b17997f72c388752826c00e0606fb69cdd1f795","bound":"$@1"}), whose bound points at $ACTION_0:1, which points at $ACTION_0:2, the encrypted text. Line 7: an action with nothing bound only needs its id, in the field's name.
On the server, such a post is multipart without next-action, so decodeAction will read it. Next.js reads the whole form, and areAllActionIdsValid (lines 1558 to 1606) checks that every id in the field names is in the manifest, and that there are at most two $ACTION_REF_ fields, before React's decodeAction (ReactFlightActionServer.js, lines 139 to 160) touches anything:
let action: Promise<(formData: FormData) => T> | null = null;
if (actionKey.startsWith('$ACTION_REF_')) {
const formFieldPrefix =
'$ACTION_' + actionKey.slice('$ACTION_REF_'.length) + ':';
const metaData = decodeBoundActionMetaData(
body,
serverManifest,
formFieldPrefix,
);
action = loadServerReference(serverManifest, metaData);
} else if (actionKey.startsWith('$ACTION_ID_')) {
const id = actionKey.slice('$ACTION_ID_'.length);
action = loadServerReference(serverManifest, {
id,
bound: null,
});
} else {
throw new Error('Cannot handle action key. This is a bug in React.');
}
// Return the action with the remaining FormData bound to the first argument.
return action.then(fn => fn.bind(null, formData));- Lines 2 to 10: for
$ACTION_REF_0, it reads the fields starting$ACTION_0:as reply parts, which gives the id and the bound values, and loads the function with them bound. - Lines 11 to 16: for
$ACTION_ID_…, the id is the rest of the field's name, with nothing bound. - Line 22 binds the rest of the form, the ordinary fields, as the next argument:
addToCartgets a FormData withtea.
Then handleAction runs it like any other (lines 1160 to 1187). A redirect becomes the 303 above. Otherwise the server renders the whole page as HTML, with the action's state passed to useActionState through decodeFormState, since there's no router to hand a payload to. Step through both journeys:
Add to cart, with JavaScriptafter step 1Mint tea
Add to cart
Tins in your cart: 0
Empty cart
Rate 5 stars
React is running: the page responds to clicks.
The page isn't complete yet.
The button submits the form, and React calls the stub for addToCart with its FormData. encodeReply turns the arguments into a body: FormData, because one argument is FormData.
Following an action in your own DevTools
In a Next.js 16.4.3 app you can watch all of this without reading any build files. The details are this version's; read them to learn and to debug, never from your app's code:
- In the browser's Network tab, click the button and pick the
POSTto the page's own address. Its request headers havenext-actionandnext-router-state-tree; its Payload is the body; its Response is the rows, withain row 0, andtwhen the page came back. - Its response headers say
x-action-revalidatedwhen something changed, andx-action-redirectwhen the action redirected. - View the page's source to find the
$ACTION_ID_and$ACTION_REF_fields of each form, for visitors without JavaScript. - In
.next/server/server-reference-manifest.json, each id names its file and export, which helps when a log only shows an id.
Sort each step of a call: where does it happen?
- Leaving out the arguments the id's first byte marks as unused
- Choosing between a text body and FormData
- Comparing the
Originheader with the host - Turning the body back into arguments
- Encrypting a closure's values into the page
- Refusing a tea that isn't on the list
- Setting the
cartcookie - Reading
x-action-redirectand moving to the new page - Answering
303to a form posted without JavaScript
Sort each step of a Server Action call: in the browser, in Next.js's server code, or in your own action.
Common mix-ups
- "An action is a JSON request to a hidden API route." No. It's a
POSTto the page's own address, and the body istext/plainormultipart/form-data, written by React'sencodeReply. - "The browser sends every argument you pass." No. It leaves out the ones the id's first byte marks as unused:
emptyCart's click sent[]. - "Every action sends back the whole page." No. Only when something was revalidated, like a cookie that changed.
rateTea's answer was two rows. - "Encrypted closure values mean only my page can call the action." No. Anyone who loads the page gets a valid value and can send it again. The action checks who is asking.
- "A request without an Origin header comes from my own site." No. Next.js lets it through with a warning, and any program can leave the header out.
- "redirect() in an action always answers 303." No. Only for a form posted without JavaScript. With JavaScript, it's a
200withx-action-redirect, and, for an address in the same app, usually the new page inside.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
An action's id starts with 70. Which of its arguments does the browser send?
first byte: 70The first three. 70 is 0 111000 0: a Server Action (type bit 0), the first three argument bits set, no rest parameter. A function declared as setBoxes(tea, boxes, note) gets it, and the browser sends at most three arguments. The byte is made in server_actions.rs, lines 308 to 369, and read by omitUnusedArgs in server-reference-info.ts, lines 66 to 89.
This is the recorded click on Rate 5 stars. Which header tells the server which function to run?
POST /
next-router-state-tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C162%5D%7D%2Cnull%2Cnull%2C82%5D
next-action: 60b30a2809fe1266ee72deff454ab74a77ef9c70e0
content-type: text/plain;charset=UTF-8
["mint",5]next-action. getServerActionRequestMetadata (server-action-request-meta.ts, lines 29 to 43) reads it, and a POST with it is a fetch action. next-router-state-tree describes what's on the screen.
This is rateTea's recorded answer. Why does row 0 have no t?
0:{"a":"$@1","q":"","i":false,"b":"<buildId>"}
1:{"saved":true,"stars":5}Nothing was revalidated. rateTea only logs and returns, so pathWasRevalidated stays undefined, skipPageRendering is true, and the answer is the result alone (action-handler.ts, lines 1473 to 1493, and app-render.tsx, lines 713 to 724). The recorded answer has no x-action-revalidated header either.
Which function gives line 2 its key?
async function encodeActionBoundArg(actionId: string, arg: string) {
const key = await getActionEncryptionKey()
if (key === undefined) {
throw new Error(
`Missing encryption key for Server Action. This is a bug in Next.js`
)
}
// Get 16 random bytes as iv.
const randomBytes = new Uint8Array(16)
workUnitAsyncStorage.exit(() => crypto.getRandomValues(randomBytes))
const ivValue = arrayBufferToString(randomBytes.buffer)
const encrypted = await encrypt(
key,
randomBytes,
textEncoder.encode(actionId + arg)
)
return btoa(ivValue + arrayBufferToString(encrypted))
}getActionEncryptionKey, in encryption-utils.ts, lines 67 to 91. It takes NEXT_SERVER_ACTIONS_ENCRYPTION_KEY if it's set, or the encryptionKey the build wrote into server-reference-manifest.json, and imports it once for AES-GCM.
Someone takes the encrypted value from saveForLater's form. Once decrypted, what must the text start with for the action to accept it?
async function decodeActionBoundArg(actionId: string, arg: string) {
const key = await getActionEncryptionKey()
if (typeof key === 'undefined') {
throw new Error(
`Missing encryption key for Server Action. This is a bug in Next.js`
)
}
// Get the iv (16 bytes) and the payload from the arg.
const originalPayload = atob(arg)
const ivValue = originalPayload.slice(0, 16)
const payload = originalPayload.slice(16)
const decrypted = textDecoder.decode(
await decrypt(key, stringToUint8Array(ivValue), stringToUint8Array(payload))
)
if (!decrypted.startsWith(actionId)) {
throw new Error('Invalid Server Action payload: failed to decrypt.')
}
return decrypted.slice(actionId.length)
}The action's id: for the Mint tea page, 400b17997f72c388752826c00e0606fb69cdd1f795. Text that decrypts but starts with another id is refused (decodeActionBoundArg, encryption.ts, lines 48 to 70), so a value made for one action can't be sent to another.
This is the recorded Add to cart form. Before JavaScript loads, which field tells the server which action to run?
<form action="" enctype="multipart/form-data" method="POST"><input type="hidden" name="$ACTION_ID_408ea8cd1822508f04521eb1e08ac7463417a9a30d"><input type="hidden" name="tea" value="mint"><button>Add to cart</button></form>$ACTION_ID_408ea8cd1822508f04521eb1e08ac7463417a9a30d. The field has no value: the id is in its name, and decodeAction takes it from there (ReactFlightActionServer.js, lines 139 to 160, at React 278794d7; React names the field in defaultEncodeFormAction, ReactFlightReplyClient.js, lines 1073 to 1122). tea stays an ordinary field and reaches addToCart in its FormData.
Check your understanding
For each question, ask which side does the work, and what the recording showed.
Question 1 of 7
emptyCart's id starts with00. What does that first byte tell the browser?Choose an answer to see the explanation.
Question 2 of 7The click on Rate 5 stars called
rateTea("mint", 5). What did the request carry?Choose an answer to see the explanation.
Question 3 of 7Why did
addToCart's answer include the page, whilerateTea's didn't?Choose an answer to see the explanation.
Question 4 of 7What is inside the encrypted
$ACTION_0:2value of the Mint tea page, once decrypted with the key?Choose an answer to see the explanation.
Question 5 of 7With JavaScript,
saveForLatercalledredirect("/saved"). What did the recorded answer look like?Choose an answer to see the explanation.
Question 6 of 7Before JavaScript has loaded, how does the server know which action the Add to cart form means?
Choose an answer to see the explanation.
Question 7 of 7A request comes in with no
Originheader at all. What does Next.js 16.4.3 do?Choose an answer to see the explanation.
Key takeaways
- An action id is one byte about the parameters and a SHA-1 hash of the build's key, the file and the name. The browser uses the byte to leave out unused arguments.
- A call is one
POSTto the page's own address, withnext-actionandnext-router-state-tree.encodeReplywrites the arguments as plain text, or as FormData when a value needs it. handleActionchecks the Origin and the size, reads the body withdecodeReply,decodeReplyFromBusboyordecodeAction, finds the function in the manifest, and runs it inside the request's store.- An inline action's values are written as Flight text, put after the action's id, encrypted with AES-GCM and the build's key, and bound as its first argument. They come back in the form and are checked and decrypted.
- The answer's row 0 holds the result in
a, and the new tree intwhen a cookie, a tag or a path changed. - A redirect is always
200withx-action-redirectwith JavaScript, and Next.js tries to add the target's payload when the address is in the same app. Without JavaScript, it's303.
Remember the one-liner.
One POST in, named by its id; one RSC payload out, with the result and, when something changed, the new UI.
Coming next: proxy.ts and runtimes under the hood. It finds where proxy.ts runs in the request pipeline, how its matchers compile, and how the Node.js and Edge runtimes differ.