cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Read an action's requestDecode an action id's first byte, and say why a call sends plain text or FormData, with which headers.
  • 02
    Follow it through the serverName what action-handler.ts checks, which function reads the body, and how the action runs inside the request.
  • 03
    Explain 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:

The Mint tea page, with a cart and three Server ActionsNext.js
app/
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 };}
Recorded from Next.js 16.4.3

Each step was taken in real Chrome against next start. Pick one to see the page after it.

localhost:3000/Tea shop
Mint tea
Add to cart

Tins in your cart: 1

Empty cart

Rate 5 stars

Focus
the button “Add to cart”
Scrolled
0 px down

Requests the page made during this step (1), without the files it loaded

  1. 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.

The browser sends POST / with next-action: 408ea8… and the form's data. On the server, action-handler checks the request, reads the arguments, finds addToCart and runs it, then renders the page again. One answer comes back: row 0 holds a, the result, and t, the new tree, and the browser shows Tins in your cart: 1.browserAdd to cartthenTins in your cart: 1POST /next-actionservercheck the requestread the argumentsfind and run addToCartrender the page againaction-handler.ts200RSC payloadrow 0: a (result), t (tree)one answer, two parts
One click on Add to cart is one 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.
Real-life analogyA mail-order form

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
The main idea

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.

A key fixed for the recording

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.

The id 408ea8cd1822508f04521eb1e08ac7463417a9a30d split in two: the first byte, 40, which is 0 100000 0 in bits: not a cache function, the first argument used, no rest parameter. Then 20 bytes of SHA-1 made from the key, app/actions.ts, a colon and addToCart.408ea8cd1822…17a9a30dfirst byteSHA-1 of key + "app/actions.ts" + ":" + "addToCart"01000000cache?arguments 1 to 6rest?not a cache function,uses only argument 1
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:

The first byte of an action id, shortened from server_actions.rsRust
// 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);
  1. Lines 2 and 3 draw the 8 bits: one type bit, six argument bits, one rest bit.
  2. Lines 5 and 6: the type bit is 1 for a "use cache" function and 0 for a Server Action.
  3. 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.
  4. Lines 13 and 14: the last bit says whether there's a ...rest parameter, or more than six.
  5. 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:

omitUnusedArgs: server-reference-info.ts, lines 66 to 89TypeScript
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:

Playground: what the first byte says
What emptyCart's id saysText
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 []
Try it yourself
emptyCart

No parameters, so no argument bits are set. The click sent []: an empty list of arguments.

The ids are from the recorded builds' server reference manifests, and each body is what real Chrome sent for that action's click. The bits are read the way server-reference-info.ts reads them. Reset goes back to emptyCart.

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.

Internals, not an API

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:

The browser's request, shortened from server-action-reducer.tsTypeScript
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 })
  1. Line 1 drops the unused arguments, as above.
  2. Line 2 writes the rest with React's encodeReply.
  3. Lines 4 to 10 are the headers: Accept: text/x-component, the type of answer it wants; next-action, the id; and next-router-state-tree, the router's description of what's on the screen.
  4. Line 12 sends a POST to state.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:

Plain text or FormData: ReactFlightReplyClient.js, lines 1008 to 1020JavaScript
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:

What each recorded call sent
CallContent typeWhat the body holds
rateTea("mint", 5), from a clicktext/plain;charset=UTF-8["mint",5]: the arguments as one line of JSON.
emptyCart(), from a formtext/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 formmultipart/form-dataPart 0 is ["$K1"], a pointer to a FormData; the form's own fields follow, each name starting with _1_.
saveForLater, from a formmultipart/form-dataPart 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:

Recorded: the body of POST / for addToCartText
--<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:

Recorded: next-router-state-tree, decodedJSON
["",{"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:

Is it a Server Action? server-action-request-meta.ts, lines 29 to 43TypeScript
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:

The body size limit: action-handler.ts, lines 843 to 851TypeScript
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 MB

Line 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:

Which function reads the body, in action-handler.ts
What arrivedWho reads itWhere
A multipart body with a next-action headerdecodeReplyFromBusboy, which reads the parts as they stream in.Lines 1081 to 1114.
A text body with a next-action headerdecodeReply, after the whole body has arrived.Lines 1193 to 1234.
A multipart body without next-actiondecodeAction, 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:

Running the action: action-handler.ts, lines 1473 to 1493TypeScript
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 }
  1. Line 1 sets the request's phase to "action". In this phase, cookies() returns cookies you can change.
  2. Lines 4 to 8 refuse more than 1,000 arguments, a guard against huge requests.
  3. 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's AsyncLocalStorage, which lets every function called from here find it without being passed it. That's how cookies() on line 10 of app/actions.ts knows which visitor it's for.
  4. 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:

A tea page whose Server Action is written inside the componentNext.js
app/saved/teas/[tea]/
app/teas/[tea]/page.tsx
import { 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>  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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="&quot;qbZzYOBcHhI4FRiXlssFQBLi8YTHNgxIl0ejZe2Qr6IilfKLvGQuI38M6QKB3+fFUKtt/NQpuzwRk2epXMg0bK5HICMpQCbXVY1i/bqz0VmHSm0RuZHo&quot;"><input type="hidden" name="$ACTION_0:1" value="[&quot;$@2&quot;]"><input type="hidden" name="$ACTION_0:0" value="{&quot;id&quot;:&quot;400b17997f72c388752826c00e0606fb69cdd1f795&quot;,&quot;bound&quot;:&quot;$@1&quot;}"><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:

Recorded from the build: the compiled tea page, from .next/server/chunks/ssr/_08wvhvb75vo66._.jsJavaScript
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));
  1. 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 the 40 in its id.
  2. Line 2 is its new first line: decryptActionBoundArgs turns that parameter back into two variables, which line 3 uses in place of tea and price.
  3. Line 6 registers it as a server reference with its id, as in the references lesson.
  4. Line 11, inside TeaPage: saveForLater is now the moved function with its first argument fixed by bind, and that argument is encryptActionBoundArgs("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:

Encrypting the bound values: encryption.ts, lines 76 to 96TypeScript
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))
}
  1. Line 2 gets the key, from getActionEncryptionKey.
  2. Lines 9 to 12 make 16 random bytes, the IV (initialization vector): a starting value that makes the same text encrypt differently each time.
  3. 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. encrypt calls the Web Crypto API's crypto.subtle.encrypt (encryption-utils.ts, lines 37 to 50).
  4. Line 20 joins the IV and the result and writes them as base64 text, safe to put in a page.
Where the key comes from: encryption-utils.ts, lines 67 to 91TypeScript
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):

Recorded: the hidden fields of the Save for later formText
$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:

The recorded value, decrypted with the recording's keyText
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:

Decrypting them: encryption.ts, lines 48 to 70TypeScript
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)
}
  1. Lines 10 to 12 undo the base64 and split off the 16 bytes of the IV.
  2. 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 500 for one.
  3. 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.
  4. Line 22 returns the Flight text, which decryptActionBoundArgs reads back into values with React's createFromReadableStream (lines 243 to 329).
While the Mint tea page renders, tea and price are written as the Flight text 0:["mint",3]. Next.js puts the action's id in front, encrypts it with AES-GCM using the key and 16 random bytes, and puts the base64 text in the hidden field $ACTION_0:2. When the form comes back, Next.js decrypts it, checks that it starts with the same id, and gives the action ["mint", 3].tea, price0:["mint",3]id + textthe closure's valueswritten as Flightthe id goes firstAES-GCM, random IV$ACTION_0:2 = "qbZz…uZHo"a hidden field in the HTMLback: decrypt, check the id["mint", 3]
The values the closure uses go into the page, encrypted with the server's key. Coming back, the text must decrypt and start with this action's id, or the action doesn't run.

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:

The answer's first row: app-render.tsx, lines 713 to 724TypeScript
// 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:

Recorded: the answer to POST / for rateTeaText
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:

Recorded: two rows of the answer to POST / for addToCartText
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:

Setting a cookie marks the page: request-cookies.ts, lines 128 to 133TypeScript
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:

The x-action-revalidated header: action-handler.ts, lines 195 to 206TypeScript
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:

What came back for each recorded call
Actionx-action-revalidatedRow 0's keysWhy
rateTeaNonea, q, i, bNothing changed, so the page wasn't rendered again: the answer is only the result.
addToCart1a, q, i, t, bIt set a cookie, so the page was rendered again, and t holds the new tree.
emptyCart1a, q, i, t, bThe 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):

Recorded: the answer to POST /teas/mint for saveForLaterText
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:

A redirect from an action: action-handler.ts, lines 1343 to 1376TypeScript
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,
  }
  1. Line 1: redirect() works by throwing a special error, so the redirect is caught here, after the action stopped.
  2. Lines 5 to 25, for a fetch action: status 200, and createRedirectRenderResult (lines 402 to 521). It sets x-action-redirect to 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 /saved it worked, so the recorded body is /saved's payload.
  3. Lines 27 to 30, for a form posted without JavaScript: 303 See Other and a Location header, and no body.

The router reads the header like this:

The router reads the redirect: server-action-reducer.ts, lines 185 to 197TypeScript
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.

Two halves. With JavaScript: POST /teas/mint answers 200 with x-action-redirect: /saved;push and the /saved page's RSC payload, and the router shows /saved with no more requests. Without JavaScript: POST /teas/mint answers 303 with location: /saved, and the browser then loads /saved with a GET.With JavaScriptWithout JavaScriptPOST /teas/mint200 · x-action-redirectbody: the /saved payloadthe router shows /savedPOST /teas/mint303 · location: /savedno bodythen: GET /saved
The same 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:

Recorded: the cart page's forms, from its HTMLHTML
<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:

The form's hidden field names, shortened from ReactFlightReplyClient.jsJavaScript
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:

Finding the action in a posted form: ReactFlightActionServer.js, lines 139 to 160JavaScript
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));
  1. 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.
  2. Lines 11 to 16: for $ACTION_ID_…, the id is the rest of the field's name, with nothing bound.
  3. Line 22 binds the rest of the form, the ordinary fields, as the next argument: addToCart gets a FormData with tea.

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:

Playground: who does what for one Server Action
Who does whatAdd to cart, with JavaScript
What the visitor seesafter step 1
tea-shop.example/

Mint 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.

Step 1 of 9
React: React collects the form

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.

Each step is one thing that happens, in order, named after the function in Next.js's or React's source that does it. The screens are the pages recorded in real Chrome and the responses recorded from next start. Reset goes back to the first journey's first step.

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 POST to the page's own address. Its request headers have next-action and next-router-state-tree; its Payload is the body; its Response is the rows, with a in row 0, and t when the page came back.
  • Its response headers say x-action-revalidated when something changed, and x-action-redirect when 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?

Who does this step?
  • Leaving out the arguments the id's first byte marks as unused
  • Choosing between a text body and FormData
  • Comparing the Origin header 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 cart cookie
  • Reading x-action-redirect and moving to the new page
  • Answering 303 to a form posted without JavaScript
Try it yourself
0 of 9 correct

Sort each step of a Server Action call: in the browser, in Next.js's server code, or in your own action.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "An action is a JSON request to a hidden API route." No. It's a POST to the page's own address, and the body is text/plain or multipart/form-data, written by React's encodeReply.
  • "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 200 with x-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.

Exercise 1 · Warm-upRead a first byte

An action's id starts with 70. Which of its arguments does the browser send?

Starter codeText
first byte: 70

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhich header names the action?

    This is the recorded click on Rate 5 stars. Which header tells the server which function to run?

    Starter codeText
    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]

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWhy is there no t?

      This is rateTea's recorded answer. Why does row 0 have no t?

      Starter codeText
      0:{"a":"$@1","q":"","i":false,"b":"<buildId>"}
      1:{"saved":true,"stars":5}

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFind it in the source: the key

        Which function gives line 2 its key?

        Starter codeTypeScript
        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))
        }

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWhat must the plaintext start with?

          Someone takes the encrypted value from saveForLater's form. Once decrypted, what must the text start with for the action to accept it?

          Starter codeTypeScript
          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)
          }

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeBefore JavaScript: which field?

            This is the recorded Add to cart form. Before JavaScript loads, which field tells the server which action to run?

            Starter codeHTML
            <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>

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which side does the work, and what the recording showed.

              The Server Actions under the hood quiz · 7 questionsScore: first tries count
              1. Question 1 of 7emptyCart's id starts with 00. What does that first byte tell the browser?

                Choose an answer to see the explanation.

              2. 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.

              3. Question 3 of 7Why did addToCart's answer include the page, while rateTea's didn't?

                Choose an answer to see the explanation.

              4. Question 4 of 7What is inside the encrypted $ACTION_0:2 value of the Mint tea page, once decrypted with the key?

                Choose an answer to see the explanation.

              5. Question 5 of 7With JavaScript, saveForLater called redirect("/saved"). What did the recorded answer look like?

                Choose an answer to see the explanation.

              6. 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.

              7. Question 7 of 7A request comes in with no Origin header 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 POST to the page's own address, with next-action and next-router-state-tree. encodeReply writes the arguments as plain text, or as FormData when a value needs it.
              • handleAction checks the Origin and the size, reads the body with decodeReply, decodeReplyFromBusboy or decodeAction, 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 in t when a cookie, a tag or a path changed.
              • A redirect is always 200 with x-action-redirect with JavaScript, and Next.js tries to add the target's payload when the address is in the same app. Without JavaScript, it's 303.

              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.

              CompleteFrontend Clear concepts. Working examples.