Next.js NotesRohit’s interview study guide
Chapter 07

Server Actions and Mutations

Writing data from a Next.js app: what a Server Action really is on the wire, forms that work before JavaScript, pending and error states, optimistic UI, updating caches after a write, and keeping every action safe.

7 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Server Actions

Added

A Server Action is an async function that runs on the server but that your client code can call as if it were local. You mark it with the 'use server' directive, pass it to a <form action> or call it from an event handler, and React and Next.js do the rest: they serialise the arguments, send them to the server, run the function, and send back its return value, plus a freshly rendered page if the action changed something the page shows. React calls the general feature a Server Function; "Server Action" is the name for a Server Function used for a mutation, through a form or a transition. Next.js uses both names, and so will this chapter.

The problem it solves is the boilerplate of every write in a classic single-page app: an API route, a fetch with a JSON body, loading flags, error handling, then refetching or patching client state so the screen shows the change. With an action, the mutation, the cache invalidation and the updated UI travel in one round trip, and a form keeps working before the JavaScript has loaded.

The mental model to keep: a Server Action is a hidden API endpoint whose URL is an ID. At build time, the compiler replaces the function in the client bundle with a small stub that knows an action ID. Calling the stub sends a POST to the current page's URL with a Next-Action: <id> header and the serialised arguments in the body. The server looks the ID up, runs the real function, and streams back the result in React's Flight format (text/x-component). Nothing about the function is secret except its body: anyone who can send that POST can run it, which is why the security topic later in this chapter exists.

Browser await like(1) like is a stub, not your code: it only knows the action ID POST / Next-Action: 401e…20ee4 body: [1] then: state = 1, UI updates Next.js server 1. Origin matches Host? 2. ID → action manifest 3. decode args, run like(1) 4. re-render the route if it called refresh, updateTag, revalidatePath or redirect 5. stream the response POST, one request any URL works: the ID decides text/x-component return value (+ new UI)
One action call is one POST. The function body never leaves the server; only its ID and the arguments travel.

Here is a small guestbook that uses all three ways of reaching an action: a form in a Server Component, a button in a Client Component, and an action defined inline inside a Server Component. The page comes first; the actions file, the like button, the store and the route table follow under "The rest of the guestbook". The "database" is an array kept on globalThis, so every route in the one next start process sees the same data.

app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { deleteEntry, listEntries } from "@/lib/guestbook";
import { refresh } from "next/cache";
import { signGuestbook } from "./actions";
import { LikeButton } from "./like-button";

async function Entries() {
  await connection(); // read the store on every request
  const entries = await listEntries();
  return (
    <ul>
      {entries.map((entry) => {
        // An inline Server Action: it closes over `entry.id` from this render.
        async function remove() {
          "use server";
          await deleteEntry(entry.id);
          console.log(`[remove] entry ${entry.id}`);
          refresh();
        }
        return (
          <li key={entry.id}>
            <b>{entry.name}</b>: {entry.message} <LikeButton id={entry.id} initial={entry.likes} />
            <form action={remove}>
              <button>Delete</button>
            </form>
          </li>
        );
      })}
    </ul>
  );
}

export default function Page() {
  return (
    <main>
      <h1>Guestbook</h1>
      <form action={signGuestbook}>
        <input name="name" placeholder="Name" required />
        <input name="message" placeholder="Message" required />
        <button>Sign</button>
      </form>
      <Suspense fallback={<p>Loading entries…</p>}>
        <Entries />
      </Suspense>
    </main>
  );
}
What’s happening
  1. app/actions.ts (below) starts with "use server", so every export of that file becomes a Server Action: signGuestbook and like. They import lib/guestbook.ts, which starts with import "server-only", so the store can never end up in a browser bundle.
  2. The page is a Server Component. It passes signGuestbook straight to <form action>. Because a Server Component never runs in the browser, Next.js renders this form as plain HTML that can submit without JavaScript (shown in "What the server renders").
  3. LikeButton is a Client Component that imports like. What it actually gets is a stub. await like(id) inside startTransition sends a POST and resolves with the server's return value, so setLikes(next) shows the count the server computed: 0 → 1.
  4. remove is defined inside the map callback, with "use server" as its first line. It closes over entry.id, a value from this particular render. The server can't keep that value around between requests, so Next.js puts it in the HTML, encrypted, and the browser sends it back when the form is submitted.
  5. Entries awaits connection(), which makes it render per request inside its <Suspense>; the heading and the form are the static shell. That's what the build's ◐ / (Partial Prerender) says.
  6. signGuestbook and remove end with refresh(), which tells Next.js to re-render the current page and send it back in the same response as the action. like doesn't, so its response carries only the number.
AdvancedThe rest of the guestbook

The actions file, the like button, the store, and the route table next build printed:

app/actions.ts
"use server";

import { refresh } from "next/cache";
import { addEntry, likeEntry } from "@/lib/guestbook";

export async function signGuestbook(formData: FormData) {
  const name = String(formData.get("name") ?? "").trim();
  const message = String(formData.get("message") ?? "").trim();
  if (!name || !message) return;
  const entry = await addEntry(name, message);
  console.log(`[signGuestbook] #${entry.id} ${name}: ${message}`);
  refresh(); // re-render the current page in this same response
}

export async function like(id: number) {
  const likes = await likeEntry(id);
  console.log(`[like] entry ${id} now has ${likes} like(s)`);
  return likes;
}
app/like-button.tsx
"use client";

import { useState, useTransition } from "react";
import { like } from "./actions";

export function LikeButton({ id, initial }: { id: number; initial: number }) {
  const [likes, setLikes] = useState(initial);
  const [pending, startTransition] = useTransition();
  return (
    <button
      disabled={pending}
      onClick={() =>
        startTransition(async () => {
          const next = await like(id); // looks like a function call; it's a POST
          setLikes(next);
        })
      }
    >
      ♥ {likes}
    </button>
  );
}
lib/guestbook.ts
import "server-only";

export type Entry = { id: number; name: string; message: string; likes: number };

// A stand-in database: one array per server process, kept on globalThis so every route sees the same one.
const store = globalThis as unknown as { guestbook?: Entry[] };
const entries = (store.guestbook ??= [{ id: 1, name: "Ada", message: "First!", likes: 0 }]);

export async function listEntries(): Promise<Entry[]> {
  return entries.map((e) => ({ ...e }));
}

export async function addEntry(name: string, message: string): Promise<Entry> {
  const entry = { id: entries.length + 1, name, message, likes: 0 };
  entries.push(entry);
  return entry;
}

export async function likeEntry(id: number): Promise<number> {
  const entry = entries.find((e) => e.id === id);
  if (!entry) throw new Error(`No entry ${id}`);
  return ++entry.likes;
}

export async function deleteEntry(id: number): Promise<void> {
  const i = entries.findIndex((e) => e.id === id);
  if (i >= 0) entries.splice(i, 1);
}
next build
Route (app)
┌ ◐ /
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedWhat the server renders

Fetching the page with curl -s http://localhost:4507/ (after next build && next start -p 4507) shows what React did with the two forms. Picked out of the HTML:

Output
<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_ID_40fb874c8cb70c4ff73d15839a25eea205687d62cb"/>
  <input placeholder="Name" required="" name="name"/>
  <input placeholder="Message" required="" name="message"/>
  …
<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_REF_2"/>
  <input type="hidden" name="$ACTION_2:1" value="[&quot;$@2&quot;]"/>
  <input type="hidden" name="$ACTION_2:0" value="{&quot;id&quot;:&quot;40cd9d8197dde780f549d8c83c11a70e5af27ee332&quot;,&quot;bound&quot;:&quot;$@1&quot;}"/>
  <input type="hidden" name="$ACTION_2:2" value="&quot;4dLfqAWX2oBd8ietP9aKX4aPmCvEI0+bGLLOjTOZLYTlBlkXexFClG+JQiYkgqEsJofVgG2Ck68F/bzHNf/Vf4XMMVevr9pWggTeR7qW6N0=&quot;"/>
What’s happening
  1. Both forms are ordinary HTML forms: method="POST", encType="multipart/form-data" and action="", which means "post to the URL I'm on". A browser can submit them with no JavaScript at all.
  2. The first form carries the action ID as the name of a hidden input, $ACTION_ID_40fb87…. When the browser posts the form, the server finds that field and knows which function to run.
  3. The second form is the inline remove action. $ACTION_2:0 says "action 40cd9d… with bound arguments $@1", and $ACTION_2:2 is the closed-over entry.id, encrypted: 80 bytes of base64-encoded ciphertext instead of 1. The browser can carry it but can't read or change it.
  4. The IDs are 42 hex characters. They're generated at build time, look random, and differ between builds; they are not derived from the function name.
AdvancedWhat the browser sends

With JavaScript running, clicking ♥ on Ada's entry doesn't post a form; the stub makes a fetch. Recorded in headless Chrome over the DevTools protocol (Network.requestWillBeSent, Network.responseReceived and Network.getResponseBody; headers trimmed to the ones that matter):

Output
POST http://localhost:4507/
  next-action: 401e09601c3122ef962683d4e71a554ed7f9720ee4
  next-router-state-tree: %5B%22%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2Cnull%2Cnull%2C49314%5D%7D%2Cnull%2Cnull%2C49234%5D
  Accept: text/x-component
  Content-Type: text/plain;charset=UTF-8
  body: [1]

200 OK
  Content-Type: text/x-component
  Cache-Control: no-cache, no-store, max-age=0, must-revalidate
  body:
0:{"a":"$@1","q":"","i":false,"b":"MiaXIN1sLAbwao82jfqSU"}
1:1

Submitting the sign form with JavaScript running sends the same kind of request, but as multipart/form-data, because the argument is a FormData:

Output
POST http://localhost:4507/
  next-action: 40fb874c8cb70c4ff73d15839a25eea205687d62cb
  Accept: text/x-component
  Content-Type: multipart/form-data; boundary=----WebKitFormBoundary8JZYQIN43gEXErBh
  body (one field per line):
    _1_$ACTION_ID_40fb874c8cb70c4ff73d15839a25eea205687d62cb = ""
    _1_name = "Linus"
    _1_message = "Hello from JS"
    0 = ["$K1"]

200 OK
  x-action-revalidated: 2
  Content-Type: text/x-component
What’s happening
  1. Both calls are a POST to the page's own URL. Next.js tells an action request apart from a page request by the next-action header, whose value is the action ID from the build.
  2. like(1) has one plain argument, so React serialises the argument list as JSON: the body is literally [1].
  3. The response row 0: is the envelope: "a":"$@1" means "the action's result is in row 1". Row 1:1 is the return value, the new like count. There is no page tree in it: like didn't call refresh(), so Next.js didn't re-render anything, and the button's own setLikes updates the screen.
  4. The form's FormData is sent as multipart. React prefixes the fields with _1_ and sends the argument list ["$K1"] ("argument 0 is the form data with prefix 1"); the server rebuilds a FormData from the prefixed fields before calling signGuestbook.
  5. signGuestbook called refresh(), so the response has x-action-revalidated: 2 and carries the re-rendered page as well (replayed with curl, the body was 5,514 bytes of Flight rows starting with the page tree). Chrome showed Linus's entry, the inputs were cleared, and a window.__marker set before the click was still there: no page load happened.
  6. next-router-state-tree describes which layouts and pages the browser currently has, so the server can send back only the parts of the tree that need to change.
AdvancedAnyone can call it

Nothing in that request needs a browser. Sending the like request with curl and no cookies, no Origin header and no page visit first:

Shell
curl -s -D - -X POST http://localhost:4507/ \
  -H "Next-Action: 401e09601c3122ef962683d4e71a554ed7f9720ee4" \
  -H "Content-Type: text/plain;charset=UTF-8" \
  --data '[2]'
Output
HTTP/1.1 200 OK
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Content-Type: text/x-component

0:{"a":"$@1","q":"","i":false,"b":"MiaXIN1sLAbwao82jfqSU"}
1:1

The server log printed [like] entry 2 now has 1 like(s): Grace's entry got a like from a terminal. Two more things the same experiment showed:

  • The URL hardly matters. Posting the same request to http://localhost:4507/nope, a path with no page, returned HTTP/1.1 404 Not Found and still ran the action: the log printed [like] entry 2 now has 1 like(s) and the body ended 1:1. The action ID picks the function, not the path.
  • An unknown ID is rejected cleanly. A made-up ID returned HTTP/1.1 409 Conflict with x-nextjs-action-not-found: 1 and the body Server Action unavailable.; the log said Failed to find Server Action "40deadbeef…". This request might be from an older or newer deployment. That's also what an old browser tab sees after you deploy a new build.
AdvancedClosures are encrypted; replays still work

What protects the inline remove action's entry.id? Two experiments with the hidden fields copied out of the HTML and posted back with Node's fetch (as a no-JavaScript browser would post them, with Origin: http://localhost:4507):

Output
tampered: one character of $ACTION_2:2 changed  →  500 text/plain
  server log: ⨯ Error [OperationError]: The operation failed for an operation-specific reason
              [cause]: [Error: Cipher job failed]
replayed: the fields exactly as served          →  200 text/html; charset=utf-8
  server log: [remove] entry 1
What’s happening
  1. The closed-over value is encrypted with an authenticated cipher. Changing a single character makes decryption fail ("Cipher job failed"), so the action never runs: a user can't edit the ciphertext to turn "delete entry 1" into "delete entry 2".
  2. Sending the fields back unchanged works perfectly, even from outside the browser, and Ada's entry was gone afterwards. Encryption stops tampering and stops the value being read; it does not stop someone from calling the action with values your page once gave them.
  3. So a closure is a good way to carry a snapshot from render time (a version number to detect conflicts, an ID the page was showing). It is not an authorisation check: remove should still verify that the caller may delete that entry.
AdvancedThe rules of 'use server'
  • Two placements. At the top of a file, every export becomes an action (the only way to define actions that Client Components can import). As the first statement of an async function inside a Server Component, only that function does, and it can close over the component's variables.
  • Only async functions can be exported from a 'use server' file, because each export becomes a remote call and remote calls are asynchronous. A file that also exports a constant fails the build (path shortened):
Output
Error: Turbopack build failed with 2 errors:
./src/app/actions.ts:3:1
Error: Only async functions are allowed to be exported in a "use server" file.
  1 | "use server";
  2 |
> 3 | export const MAX_LENGTH = 280;
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  • You can't define an action inside a Client Component; import it from a 'use server' file, or receive it as a prop from a Server Component (actions are the one kind of function that can cross the server/client boundary as a prop; see Passing data across the boundary).
  • Arguments and return values must be serialisable by React: primitives, plain objects and arrays, Date, Map, Set, FormData, typed arrays, Promises. Class instances and ordinary functions are not.
  • 'use server' is not "server-only". It means "callable from the client". A helper that must never be callable belongs in a module with import "server-only", not in a 'use server' file.
AdvancedWhere the IDs live, and deployments

next build writes .next/server/server-reference-manifest.json, which maps each ID to its module, export name and the pages that can run it. For the guestbook (paths shortened):

Output
40cd9d8197dde780f549d8c83c11a70e5af27ee332 $$RSC_SERVER_ACTION_0 src/app/page.tsx ['app/page']
401e09601c3122ef962683d4e71a554ed7f9720ee4 like                  src/app/actions.ts ['app/page']
40fb874c8cb70c4ff73d15839a25eea205687d62cb signGuestbook         src/app/actions.ts ['app/page']

Two production consequences follow. New builds usually get new IDs (Next.js rotates them at least every 14 days even when the code doesn't change), so a tab still running the previous build can hit the 409 "Failed to find Server Action" you saw above: deploy with rolling updates and treat that error as "please refresh". And the closure encryption key is generated per build, so when you run several instances, set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY (a base64 AES key, e.g. from openssl rand -base64 32) to the same value everywhere, or one instance can't decrypt what another rendered.

AdvancedHow it used to work
VersionWhat changed
Next.js 13.4Server Actions arrive as an alpha, behind experimental: { serverActions: true }.
Next.js 14Server Actions are stable and on by default. Pending/error state comes from useFormState and useFormStatus in react-dom (React canary).
Next.js 15React 19: useFormState is replaced by useActionState from react. Action IDs become unguessable and rotate between builds; unused actions are removed from the build. next/form and unstable_rethrow are added. React's docs start saying "Server Functions".
Next.js 16updateTag and refresh (Server Actions only) are added; revalidateTag takes a second argument, a cache profile such as 'max'. Cache Components ('use cache', cacheTag) become the caching model these functions invalidate.

Interviews still describe actions as "experimental" or show useFormState; both are pre-15 knowledge.

The like action, posted to a 404 page

Someone copies the like request from DevTools and replays it with curl, but to http://localhost:4507/does-not-exist instead of /. What status do they get, and does the like count change?

Show answer

They get 404 Not Found, and the count does change.

What’s happening
  1. Next.js routes the request by path first: there's no page at /does-not-exist, so the response status becomes 404.
  2. But the request carries Next-Action: 401e…. The action handler looks that ID up in the manifest, finds like in src/app/actions.ts, and runs it with the decoded body [2].
  3. Observed on 16.4.3: status 404 Not Found, body 1:1, and the log line [like] entry 2 now has 1 like(s).
  4. The lesson: you can't protect an action by guarding a URL (in proxy, a CDN rule or a page's own check). The only place a check reliably runs is inside the action.

#Forms with Server Actions

Added

React 19 lets <form action> take a function instead of a URL, and <button formAction> override it per button. On submit, React builds a FormData from the form, calls the function inside a transition, and resets the form's uncontrolled fields when it finishes. No onSubmit, no preventDefault, no controlled inputs for a simple form. When the function is a Server Action, Next.js adds the part React can't do alone: it renders the form as a real HTML form that posts to the server, so the form works before the JavaScript has downloaded, or without it.

The mental model: one form, two submission engines. Before hydration (slow network, JavaScript failed or turned off), the browser's own form submission posts the fields as multipart to the current URL, the server runs the action and answers with a whole HTML page or a redirect. After hydration, React intercepts the submit and sends the same fields with fetch, and the answer is a Flight stream that updates the page in place. Your action receives a FormData either way; it can't tell which engine sent it, which is exactly the point of progressive enhancement.

Next.js 15 also added next/form, a <Form> component for the other kind of form: a search or filter form that should navigate to a URL with the fields as search params (a GET), but as a client-side navigation instead of a full page load.

Here's a contact page with both kinds: a search <Form>, a message form with two submit buttons, and two ways to pass an extra argument (an event ID) to an action. The page comes first; the actions it uses (send, saveDraft, rsvp) are under "The rest of the contact app".

app/page.tsx
import { Suspense } from "react";
import Form from "next/form";
import { connection } from "next/server";
import { listMessages } from "@/lib/inbox";
import { rsvp, saveDraft, send } from "./actions";
import { RsvpButtons } from "./rsvp-buttons";

async function Inbox() {
  await connection();
  const messages = await listMessages();
  return (
    <ul>
      {messages.map((m) => (
        <li key={m.id}>
          {m.draft ? "[draft] " : ""}
          {m.from}: {m.body}
        </li>
      ))}
    </ul>
  );
}

export default function Page() {
  return (
    <main>
      <Form action="/search">
        <input name="q" placeholder="Search messages" />
        <button>Search</button>
      </Form>

      <form action={send}>
        <input name="from" placeholder="Your email" type="email" required />
        <textarea name="body" placeholder="Message" required />
        <button>Send</button>
        <button formAction={saveDraft} formNoValidate>
          Save draft
        </button>
      </form>

      <h2>Launch party</h2>
      <RsvpButtons eventId="launch-party" />
      <form action={rsvp.bind(null, "team-lunch")}>
        <button name="answer" value="yes">Going to lunch</button>
      </form>

      <Suspense fallback={<p>Loading inbox…</p>}>
        <Inbox />
      </Suspense>
    </main>
  );
}
What’s happening
  1. The message form's action is send, a Server Action, so both submit buttons post to the server. "Save draft" has formAction={saveDraft}: that button calls a different action with the same fields, and formNoValidate lets a half-written draft skip the required and type="email" checks that "Send" gets from the browser.
  2. send (in app/actions.ts) reads the fields with formData.get("from") and formData.get("body"). Values come back as FormDataEntryValue (a string, or a File for file inputs), which is why the code wraps them in String(...). Every value is a string chosen by the client; nothing here is validated yet (that's the next topic).
  3. rsvp(eventId, formData) needs an argument that isn't a form field. rsvp.bind(null, eventId) creates a new action with the first argument filled in, and the form supplies formData as the second. The client component and the Server Component (rsvp.bind(null, "team-lunch")) do the same thing.
  4. <Form action="/search"> has a string action, so it is a GET form: submitting navigates to /search?q=…. With JavaScript, next/form turns that into a client-side navigation and prefetches /search's layout and loading UI when the form scrolls into view.
  5. The Inbox list and the search results read data per request (connection(), searchParams) inside <Suspense>, which is why both routes are ◐.
AdvancedThe rest of the contact app

The store, the actions, the RSVP client component, the search page and the route table:

lib/inbox.ts
import "server-only";

export type Message = { id: number; from: string; body: string; draft: boolean };

const store = globalThis as unknown as { inbox?: Message[] };
const messages = (store.inbox ??= []);

export async function saveMessage(from: string, body: string, draft: boolean) {
  const message = { id: messages.length + 1, from, body, draft };
  messages.push(message);
  return message;
}

export async function listMessages() {
  return messages.map((m) => ({ ...m }));
}
app/actions.ts
"use server";

import { refresh } from "next/cache";
import { saveMessage } from "@/lib/inbox";

export async function send(formData: FormData) {
  console.log("[send] keys:", [...formData.keys()]);
  await saveMessage(String(formData.get("from")), String(formData.get("body")), false);
  refresh();
}

export async function saveDraft(formData: FormData) {
  await saveMessage(String(formData.get("from")), String(formData.get("body")), true);
  refresh();
}

export async function rsvp(eventId: string, formData: FormData) {
  console.log(`[rsvp] event ${eventId}: ${formData.get("answer")}`);
}
app/rsvp-buttons.tsx
"use client";

import { rsvp } from "./actions";

export function RsvpButtons({ eventId }: { eventId: string }) {
  const rsvpForEvent = rsvp.bind(null, eventId); // pre-fills the first argument
  return (
    <form action={rsvpForEvent}>
      <button name="answer" value="yes">Going</button>
      <button name="answer" value="no">Not going</button>
    </form>
  );
}
app/search/page.tsx
import { Suspense } from "react";
import { listMessages } from "@/lib/inbox";

async function Results({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
  const { q = "" } = await searchParams;
  const hits = (await listMessages()).filter((m) => m.body.includes(q));
  return (
    <p>
      {hits.length} message(s) contain “{q}”
    </p>
  );
}

export default function SearchPage({ searchParams }: PageProps<"/search">) {
  return (
    <main>
      <h1>Search</h1>
      <Suspense fallback={<p>Searching…</p>}>
        <Results searchParams={searchParams as Promise<{ q?: string }>} />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ◐ /
├ ○ /_not-found
└ ◐ /search


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedThe HTML before hydration

What curl -s http://localhost:4507/ returned for the forms and buttons:

Output
<form action="/search"><input placeholder="Search messages" name="q"/>…
<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_ID_40b7513038c3c1fcf6c6be589618a646acb70e08b6"/>…
<button formNoValidate="" name="$ACTION_ID_40533266f2bfba6410cbee954f0662979d93f84eff"
        formAction="" formEncType="multipart/form-data" formMethod="POST">Save draft
<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_REF_4"/>
  <input type="hidden" name="$ACTION_4:0" value="{&quot;id&quot;:&quot;60a755da8648c041352540a0341fcf70506a6cb04f&quot;,&quot;bound&quot;:&quot;$@1&quot;}"/>
  <input type="hidden" name="$ACTION_4:1" value="[&quot;launch-party&quot;]"/>…
<form action="" encType="multipart/form-data" method="POST">
  <input type="hidden" name="$ACTION_REF_3"/>
  <input type="hidden" name="$ACTION_3:1" value="[&quot;team-lunch&quot;]"/>…
What’s happening
  1. next/form with a string action renders a plain <form action="/search">: without JavaScript it's an ordinary GET form.
  2. The "Save draft" button carries its own action ID as its name, plus formAction="", formMethod="POST" and formEncType. When that button submits the form, the browser includes the button's name, so the server runs saveDraft instead of send.
  3. The two RSVP forms show where bound arguments go: $ACTION_4:1 is ["launch-party"] and $ACTION_3:1 is ["team-lunch"], in plain text. Only variables captured by an inline action's closure are encrypted (the guestbook's $ACTION_2:2); arguments added with .bind are not, whether you call bind in a Server or a Client Component.
  4. Both RSVP forms point at the same action ID, 60a755…: binding doesn't create a new endpoint, it pre-fills arguments that travel with the request.
AdvancedWhat JavaScript changes

The same clicks in headless Chrome, recorded over the DevTools protocol (request bodies with the multipart boundaries removed):

Output
Send          next-action: 40b7513038c3c1fcf6c6be589618a646acb70e08b6
              _1_$ACTION_ID_40b75… = ""  _1_from = "ada@example.com"  _1_body = "Hello"  0 = ["$K1"]
Save draft    next-action: 40533266f2bfba6410cbee954f0662979d93f84eff
              _1_$ACTION_ID_40b75… = ""  _1_from = ""  _1_body = "half-written"  0 = ["$K1"]
Going         next-action: 60a755da8648c041352540a0341fcf70506a6cb04f
              _1_$ACTION_REF_4 … _1_answer = "yes"  0 = ["launch-party","$K1"]
Search        GET http://localhost:4507/search?q=Hello&_rsc=s2lx3jDSrF3nwoRf   [Fetch, rsc: 1]
              → URL /search?q=Hello, page text "1 message(s) contain “Hello”", window.__marker still set

Server log for the same clicks:

Output
[send] keys: [
  '$ACTION_ID_40b7513038c3c1fcf6c6be589618a646acb70e08b6',
  'from',
  'body'
]
[rsvp] event launch-party: yes
[rsvp] event team-lunch: yes
What’s happening
  1. After hydration, the action is chosen by the next-action header, not by a form field: "Save draft" sends saveDraft's ID in the header even though the body still contains the form's hidden $ACTION_ID_40b75… input.
  2. That hidden input comes through to your action. With JavaScript, [...formData.keys()] in send listed $ACTION_ID_40b75… before from and body; in the no-JavaScript submission shown below, the log listed only from and body. So Object.fromEntries(formData) can contain extra $ACTION_… keys; pick the fields you need by name (or parse with a schema) instead of spreading the whole object into a database call.
  3. For the bound RSVP, the argument list is ["launch-party","$K1"]: argument 0 is the bound event ID as plain JSON, argument 1 is the form data.
  4. The search form made one GET with an _rsc query and the rsc: 1 header, which asks for the RSC payload of /search rather than HTML. The URL changed, the results rendered, and a variable set on window before the click survived: a client-side navigation, not a page load.
AdvancedBound arguments are client input

Because the bound value travels in plain text, a user can change it. Sending the RSVP action with a made-up event:

Shell
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:4507/ \
  -H "Origin: http://localhost:4507" \
  -H "Next-Action: 60a755da8648c041352540a0341fcf70506a6cb04f" \
  -F '_1_answer=yes' -F '0=["ceo-only-offsite","$K1"]'
Output
200
server log: [rsvp] event ceo-only-offsite: yes

The same happened with the no-JavaScript form fields: posting $ACTION_4:1=["board-meeting"] instead of ["launch-party"] returned 200 and logged [rsvp] event board-meeting: yes. The forms guide offers a hidden input as the alternative to .bind and warns that its value "will be part of the rendered HTML and will not be encoded", which reads as if .bind were different. It never says bound values are encrypted (the docs promise encryption only for variables an inline action closes over), and the experiment shows .bind is no different from a hidden input. Treat a bound argument exactly like a form field. Check in the action that this user may RSVP to that event. If you need a value the client must not change, read it on the server (from the session or the database), or capture it in an inline action's closure, which is encrypted.

AdvancedWith JavaScript turned off

Progressive enhancement is easy to claim and easy to break, so here it is in a real browser: Chrome with page scripts disabled (Emulation.setScriptExecutionDisabled), filling the fields and clicking the buttons as a user would. A second small app isolates the two cases that matter: an action that redirects, and one that doesn't.

app/actions.ts
"use server";

import { redirect } from "next/navigation";

export async function subscribe(formData: FormData) {
  console.log(`[subscribe] ${formData.get("email")}`);
  redirect(`/thanks?email=${encodeURIComponent(String(formData.get("email")))}`);
}

export async function subscribeNoRedirect(formData: FormData) {
  console.log(`[subscribeNoRedirect] ${formData.get("email")}`);
}
app/page.tsx
import { subscribe, subscribeNoRedirect } from "./actions";

export default function Page() {
  return (
    <main>
      <h1>Newsletter</h1>
      <form action={subscribe}>
        <input name="email" type="email" required />
        <button>Subscribe</button>
      </form>
      <form action={subscribeNoRedirect}>
        <input name="email" type="email" required />
        <button>Subscribe (no redirect)</button>
      </form>
    </main>
  );
}
app/thanks/page.tsx
import { Suspense } from "react";

async function Email({ searchParams }: { searchParams: Promise<{ email?: string }> }) {
  const { email } = await searchParams;
  return <p>We'll write to {email}.</p>;
}

export default function Thanks({ searchParams }: PageProps<"/thanks">) {
  return (
    <main>
      <h1>Thanks for subscribing</h1>
      <Suspense fallback={<p>…</p>}>
        <Email searchParams={searchParams as Promise<{ email?: string }>} />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ◐ /thanks


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content

What Chrome did with JavaScript disabled:

Output
Subscribe (redirects)
  POST http://localhost:4507/
  GET  http://localhost:4507/thanks?email=ada%40example.com   (after 303)
  page text: "Thanks for subscribing" and, below it, the fallback "…"

Subscribe (no redirect)
  POST http://localhost:4507/  → 200 text/html, the whole page again
  page text: "Newsletter  Subscribe  Subscribe (no redirect)"

Send on the contact page (◐ route, no redirect)
  POST http://localhost:4507/  → 200 text/html; charset=utf-8
  page text: ""  (the body held only a hidden <div id="S:0"> and scripts)

And with JavaScript on, the redirecting form again:

Output
POST http://localhost:4507/   [Fetch]  → 200
  x-action-redirect: /thanks?email=ada%40example.com;push
  content-type: text/x-component
URL afterwards: /thanks?email=ada%40example.com, window.__marker still set
page text: "Thanks for subscribing  We'll write to ada@example.com."
JavaScript off: browser form submission Browser full page loads Server runs subscribe() 1. POST / (multipart, $ACTION_ID_… field) 2. 303 See Other, Location: /thanks?email=… 3. GET /thanks?email=… → HTML JavaScript on: React's fetch Browser same document Server runs subscribe() 1. POST / with Next-Action header 2. 200, x-action-redirect: /thanks…;push + the /thanks payload in the same body
A redirect after a mutation costs two requests without JavaScript (Post/Redirect/Get) and one with it.
What’s happening
  1. Without JavaScript, the redirecting action works like a classic server-rendered app: the POST is answered with 303 See Other, so the browser follows it with a GET (a 303 always switches to GET), and refreshing the thanks page doesn't resubmit the form. This is the Post/Redirect/Get pattern, and Next.js does it for you when an action calls redirect() during a no-JavaScript submission.
  2. On the static (○) newsletter page, the non-redirecting action also works without JavaScript: the server runs it and answers the POST with the whole page as HTML.
  3. On the ◐ contact page, the same kind of submission ran the action (the message was saved and logged) but the page the user got back was blank: the 200 response (with x-nextjs-cache: HIT and x-nextjs-prerender: 1) contained only the streamed dynamic part, the inbox inside <div hidden id="S:0"> and its scripts, with no <html>, <head> or static shell around it. Observed on Next.js 16.4.3 with Cache Components, re-checked with curl and in Chrome, and the same with partialPrefetching: false. The docs describe Server Action forms as progressively enhanced and don't mention this; with JavaScript on, the same page works normally.
  4. Even on the redirect path, "Thanks for subscribing" showed but the email line stayed at its … fallback. Without JavaScript, nothing streamed in after the first HTML ever replaces a Suspense fallback, because React swaps streamed content in with small inline scripts. The same happened to the contact page's "Loading inbox…" on a plain GET.
  5. With JavaScript, the redirect costs one request: the response carries x-action-redirect: /thanks…;push and the destination's payload, and the router navigates without a page load (push is the default history mode for redirects from actions).
Advancednext/form in short
action is…Behaves likeExtras from Next.js
a string ("/search")a GET form; fields become search paramsprefetches the target's layout and loading UI when visible; client-side navigation on submit; replace, scroll and prefetch props
a function (a Server Action)React's <form action={fn}>none: replace and scroll are ignored; it can't prefetch because the destination isn't known until the action runs

Use <Form> for search and filter forms whose result should be a URL someone can bookmark. Use <form action={serverAction}> (or <Form action={serverAction}>, which is the same thing) for writes. Don't set method, encType or target on <Form>; they replace its behaviour with the browser's.

AdvancedFormData details that bite
  • Checkboxes send nothing when unchecked, so formData.get("agree") is null, not "off". A checked box sends "on" unless it has a value.
  • Several values under one name (multi-select, repeated checkboxes): formData.getAll("tag") returns all of them; get returns only the first.
  • Numbers are strings. "42" needs Number(...) and a check for NaN; a schema does both (next topic).
  • Files arrive as File objects in the same FormData. The whole request body counts against serverActions.bodySizeLimit (1MB by default), including multipart overhead; see Securing Server Actions.
  • Reset after submit. React resets the form's uncontrolled fields after the action completes; inputs whose value comes from state are not reset.
AdvancedOld knowledge: useFormState, experimental flags and API routes

#Pending, errors and validation

Added

A form that writes data has three outcomes to show the user, and they need three different mechanisms:

  • Pending: the request is in flight. Disable the button, show "Saving…". React tracks this for you: useActionState returns an isPending flag for the action it wraps, and useFormStatus lets any component inside a <form> read that form's status.
  • Expected failure: the input is wrong or the email is taken. That's not an exception; it's a result. The action returns it as data, and useActionState makes the returned value the component's state.
  • Unexpected failure: the database is down. The action throws, and the error goes to the nearest error.tsx boundary. In production, the client never sees the real message, only a digest that matches the server log.

The mental model for useActionState is useReducer whose reducer runs on the server: (previousState, formData) => nextState, where each submission produces the next state. The React course covers the hook itself; this topic is about what changes when the reducer is a Server Action: validation must happen there, the state travels over the network, and without JavaScript it travels through the HTML.

Validation belongs in the action. HTML attributes like required and type="email" are a convenience for honest users; the action is a public endpoint (see Server Actions), so it must check every field itself. A schema library such as Zod turns "is this FormData valid?" into one safeParse call that also gives you per-field messages.

Below: the form component, then what it showed in headless Chrome, sampled 0, 300, 700 and 1,500 ms after each click on "Create account" (the action, under "The rest of the signup app", waits 1,000 ms before answering).

app/signup-form.tsx
"use client";

import { useActionState } from "react";
import { signup, type SignupState } from "./actions";
import { SubmitButton } from "./submit-button";

const initialState: SignupState = { message: "", errors: {}, email: "" };

export function SignupForm() {
  const [state, formAction, isPending] = useActionState(signup, initialState);
  return (
    <form action={formAction} aria-busy={isPending}>
      <label>
        Email
        <input name="email" defaultValue={state.email} />
      </label>
      {state.errors.email && <p className="error">{state.errors.email.join(", ")}</p>}
      <label>
        Password
        <input name="password" type="password" />
      </label>
      {state.errors.password && <p className="error">{state.errors.password.join(", ")}</p>}
      <SubmitButton />
      <p aria-live="polite">{state.message}</p>
    </form>
  );
}
Result
not-an-email / 123
    0 ms   button "Creating account…" (disabled), aria-busy="true", no errors yet
  700 ms   button "Creating account…" (disabled), aria-busy="true"
 1500 ms   button "Create account", errors "Enter a valid email address" and
           "Use at least 8 characters", message "Please fix the errors below.",
           email field still "not-an-email", password field ""
taken@example.com / longenough
    0 ms   button "Creating account…", the previous two errors still on screen
 1500 ms   error "That email is already registered", message ""
ada@example.com / longenough
 1500 ms   message "Welcome aboard, ada@example.com!", no errors, email field ""
bob@crash.test / longenough
 1500 ms   error.tsx: "Something went wrong", "Minified React error #441; visit
           https://react.dev/errors/441 for the full message …", "Digest: 1545857813"
server log ⨯ Error: Database connection lost
             digest: '1545857813'
What’s happening
  1. useActionState(signup, initialState) returns [state, formAction, isPending]. The form's action is formAction, a wrapper: on submit, React calls signup(previousState, formData) on the server, and whatever it returns becomes the new state. That's why signup takes prev as its first parameter, unlike a plain form action.
  2. On submit, isPending turns true at once (aria-busy="true" at 0 ms) and SubmitButton's useFormStatus() reports pending: true for the form it sits in, so the button disables itself and reads "Creating account…". It stays that way while the server sleeps, then everything flips back when the response arrives (between 700 and 1,500 ms).
  3. not-an-email / 123 fails Signup.safeParse in the action (app/actions.ts, below). z.flattenError(parsed.error).fieldErrors gives { email: ["Enter a valid email address"], password: ["Use at least 8 characters"] }, and the action returns it with the typed email. Nothing is thrown, so no error boundary is involved.
  4. After the action completes, React resets the form's uncontrolled inputs: the password went back to "". The email field shows not-an-email again because its defaultValue is state.email, which the action sent back. That's the standard way to keep what the user typed after a validation error.
  5. On the second submit, the old errors are still on screen while it's pending (0 ms): state only changes when the action returns. If stale errors during a retry confuse users, hide them while isPending is true.
  6. The @crash.test address makes the action throw. The response status was 500, the nearest error.tsx rendered, and error.message in the browser was React's generic production message (#441: "An error occurred in the Server Components render. The specific message is omitted in production builds…"); "Database connection lost" appeared only in the server log, with the same digest 1545857813 the page showed. The digest is how you find the real error in your logs.
AdvancedThe rest of the signup app

The store, the action with its Zod schema, the submit button, the page, the error boundary and the route table:

lib/users.ts
import "server-only";

const store = globalThis as unknown as { users?: Set<string> };
const users = (store.users ??= new Set(["taken@example.com"]));

export async function emailTaken(email: string) {
  return users.has(email);
}

export async function createUser(email: string) {
  users.add(email);
}
app/actions.ts
"use server";

import { z } from "zod";
import { createUser, emailTaken } from "@/lib/users";

export type SignupState = {
  message: string;
  errors: { email?: string[]; password?: string[] };
  email: string; // what the user typed, so the field can be refilled
};

const Signup = z.object({
  email: z.email("Enter a valid email address"),
  password: z.string().min(8, "Use at least 8 characters"),
});

export async function signup(prev: SignupState, formData: FormData): Promise<SignupState> {
  await new Promise((r) => setTimeout(r, 1000)); // a slow database, so the pending state shows

  const input = { email: String(formData.get("email") ?? ""), password: String(formData.get("password") ?? "") };
  const parsed = Signup.safeParse(input);
  if (!parsed.success) {
    return { message: "Please fix the errors below.", errors: z.flattenError(parsed.error).fieldErrors, email: input.email };
  }
  if (await emailTaken(parsed.data.email)) {
    return { message: "", errors: { email: ["That email is already registered"] }, email: input.email };
  }
  if (parsed.data.email.endsWith("@crash.test")) {
    throw new Error("Database connection lost"); // an unexpected failure, not a validation result
  }
  await createUser(parsed.data.email);
  return { message: `Welcome aboard, ${parsed.data.email}!`, errors: {}, email: "" };
}
app/submit-button.tsx
"use client";

import { useFormStatus } from "react-dom";

export function SubmitButton() {
  const { pending } = useFormStatus(); // the status of the <form> this button sits in
  return <button disabled={pending}>{pending ? "Creating account…" : "Create account"}</button>;
}
app/page.tsx
import { SignupForm } from "./signup-form";

export default function Page() {
  return (
    <main>
      <h1>Sign up</h1>
      <SignupForm />
    </main>
  );
}
app/error.tsx
"use client";

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    <main>
      <h1>Something went wrong</h1>
      <p>{error.message}</p>
      <p>Digest: {error.digest}</p>
      <button onClick={reset}>Try again</button>
    </main>
  );
}
next build
Route (app)
┌ ○ /
└ ○ /_not-found


○  (Static)  prerendered as static content
AdvancedThe same form without JavaScript

useActionState with a Server Action also works before hydration. Chrome with scripts disabled, submitting nope / 123, then grace@example.com / longenough:

Output
after the first submit (POST / → 200, full page):
  errors: "Enter a valid email address", "Use at least 8 characters"
  message: "Please fix the errors below."   email field: "nope"   password field: ""
  hidden inputs:
    $ACTION_REF_1=
    $ACTION_1:0={"id":"602d08b0cec3294428926d192c89b3e7b1c5660a58","bound":"$@1"}
    $ACTION_1:1=[{"message":"Please fix the errors below.","errors":{"email":["Enter a valid email address"],"password":["Use at least 8 characters"]},"email":"nope"}]
    $ACTION_KEY=k9776f1b90fc5d4a8910245442ece2c48
after the second submit:
  message: "Welcome aboard, grace@example.com!"   email field: ""
  $ACTION_1:1=[{"message":"Welcome aboard, grace@example.com!","errors":{},"email":""}]
What’s happening
  1. With no JavaScript, the browser posts the form natively. The server runs signup, then renders the page with the returned state, so the errors and the refilled email arrive as plain HTML in a 200 response. (This page is fully static, ○, so the no-JavaScript response is a complete page; see the ◐ caveat in Forms with Server Actions.)
  2. Where does prev come from on the next submission? From the page: $ACTION_1:1 holds the current state as plain JSON, bound to the action as its first argument. Each submission sends the previous state back to the server.
  3. $ACTION_KEY identifies which useActionState hook the result belongs to, so React can hand the state to the right component when it renders the response.
  4. The consequence: prev is client input. A user can edit that hidden field (or the JSON in a fetch body) and send any "previous state" they like. Use prev for display continuity only; never store "attempts left", "already paid" or a user ID in it and trust it on the way back.
AdvancedReturning errors vs throwing
SituationDo thisWhat the user sees
Invalid input, duplicate email, "out of stock"return { errors, message }Messages next to the fields; the form stays usable
Not signed in, not allowedReturn an error, or redirect('/login'), or forbidden() / unauthorized() with the experimental authInterrupts flagA clear message or the login page
Database down, bug, timeoutLet it throw (or throw your own Error)The nearest error.tsx, with a digest; the real message only in server logs
Not foundnotFound()not-found.tsx

Returned errors are part of your UI contract; thrown errors are for the unexpected. A thrown error also ends the transition: React drops any optimistic state and, per React's docs, cancels actions queued behind it. More on what reaches the client in Errors in Server Actions and Route Handlers.

AdvancedZod in a Server Action

The installed Zod is 4.6.7. Two Zod 4 details matter: format checks are top-level (z.email(...), not only z.string().email()), and the old error.flatten() is now z.flattenError(error). The docs' forms guide still shows a Zod 3 option (invalid_type_error). A quick check in Node with the same kind of schema:

JavaScript
const { z } = require("zod");
const s = z.object({ email: z.email("Enter a valid email"), password: z.string().min(8, "At least 8 characters"), plan: z.enum(["free", "pro"]) });
const r = s.safeParse({ email: "nope", password: "123", plan: "gold" });
console.log(JSON.stringify(z.flattenError(r.error).fieldErrors));
Output
{"email":["Enter a valid email"],"password":["At least 8 characters"],"plan":["Invalid option: expected one of \"free\"|\"pro\""]}
What’s happening
  1. safeParse never throws; it returns { success: true, data } or { success: false, error }, which maps directly to "return an error state" in an action.
  2. Every failing field is reported at once (email, password and plan), so the user can fix them all in one go.
  3. On success, use parsed.data, not the raw FormData: it's typed, trimmed of unknown keys (such as the $ACTION_… fields), and coerced if you used z.coerce.number() and friends.
  4. Share the schema with the client if you also want instant client-side messages, but keep the server check: the action can be called without your form.
AdvanceduseFormStatus or isPending?

Both read the same thing at different distances. isPending from useActionState belongs to the component that owns the action and the form. useFormStatus() (from react-dom) is for a component inside a <form>, typically a design-system submit button, and also gives you data (the submitted FormData), method and action. It reports on its parent form only: call it in the component that renders the <form> and it's pending: false forever. To track a Server Action called from a click handler (no form), use useTransition and its isPending, as the guestbook's like button did.

Stale errors during a retry

In the signup form, the first submission returned two field errors. The user fixes the email to taken@example.com, types a long password and clicks "Create account" again. What is on screen 300 ms later, and what is on screen after the response?

Show answer

At 300 ms: the button reads "Creating account…" and is disabled, and the two old errors are still displayed. After the response: one error, "That email is already registered".

What’s happening
  1. Clicking starts a transition: isPending becomes true and useFormStatus in SubmitButton reports pending: true, so the button changes immediately.
  2. state is still the result of the previous submission, because useActionState only replaces it when the new action returns. So state.errors still holds the email and password messages. The browser sample at 0 and 300 ms showed exactly that.
  3. The action sleeps 1 s, the schema passes, emailTaken returns true, and it returns { errors: { email: ["That email is already registered"] } }. That becomes the new state: the password error disappears and the email error changes.
  4. If you'd rather not show old errors during a retry, render them only when !isPending, or keep them and dim them: it's a design choice, but now you know where the behaviour comes from.
AdvancedVersion notes

#Optimistic updates with useOptimistic

Added

A Server Action takes a network round trip, and the todos app below makes it a slow one (1 second) on purpose. Waiting a second to see the item you just typed feels broken, even though nothing is wrong. An optimistic update shows the expected result immediately and lets the server confirm it later. React 19's useOptimistic does this with no rollback code: you give it the real value (here, the list from the server) and a function that applies a pending change; it returns a value to render that includes your pending changes only while an action is running.

The mental model is a transparent overlay. The real data stays underneath, untouched. While the action is pending, React draws your guess on top of it. When the action's transition ends, the overlay is simply discarded and you see whatever is underneath then. In Next.js that last part is what makes it work so neatly: the action calls refresh() (or updateTag), so the same response that ends the action also brings the re-rendered page with the real list as new props. The overlay disappears and the real item appears in the same commit. If the write failed, the real list never changed, so dropping the overlay is the rollback.

The React course's useOptimistic topic covers the hook in isolation; here it's wired to a Server Action and server-rendered data.

Below: the list component, then what a MutationObserver in headless Chrome recorded after each click on "Add" (time in ms since the click). The store, the action and the page are under "The rest of the todos app".

app/todo-list.tsx
"use client";

import { startTransition, useOptimistic, useState } from "react";
import type { Todo } from "@/lib/todos";
import { addTodo } from "./actions";

type Shown = Todo & { saving?: boolean };

let tempId = 0; // temporary ids for optimistic rows: negative, unique, made outside the reducer

export function TodoList({ todos }: { todos: Todo[] }) {
  const [error, setError] = useState("");
  const [shown, addOptimistic] = useOptimistic<Shown[], Shown>(todos, (current, pending) => [...current, pending]);

  async function add(formData: FormData) {
    const text = String(formData.get("text"));
    addOptimistic({ id: --tempId, text, saving: true }); // visible on the next frame, while the action is pending
    const result = await addTodo(text);
    startTransition(() => setError(result.error ?? ""));
  }

  return (
    <>
      <ul>
        {shown.map((t) => (
          <li key={t.id} style={{ opacity: t.saving ? 0.5 : 1 }}>
            {t.text}
            {t.saving && " (saving…)"}
          </li>
        ))}
      </ul>
      {error && <p role="alert">{error}</p>}
      <form action={add}>
        <input name="text" required />
        <button>Add</button>
      </form>
    </>
  );
}
Result
"Buy milk"
     0  Write the chapter
     4  Write the chapter | Buy milk (saving…)
  1025  Write the chapter | Buy milk

"This will fail"
     0  Write the chapter | Buy milk
     2  Write the chapter | Buy milk | This will fail (saving…)
  1013  Write the chapter | Buy milk || alert: Couldn't save “This will fail”
What’s happening
  1. page.tsx reads the todos on the server (per request, thanks to connection()) and passes them to the client TodoList as the todos prop. That prop is the real state; the client never edits it.
  2. useOptimistic(todos, reducer) returns shown, which equals todos when nothing is pending. The form's action is add, a client function, so React runs it inside a transition. addOptimistic({ id: -1, text: "Buy milk", saving: true }) runs the reducer and shown becomes the real list plus that row: on screen 4 ms after the click, half-transparent with "(saving…)". The temporary id is made in add, not in the reducer, for a reason shown in "Things to get right".
  3. await addTodo(text) is the Server Action. On the server, insertTodo waits 1 s, pushes the todo, and the action calls refresh(), so its response carries the re-rendered page with the new todos prop as well as the return value {}.
  4. When the response is applied, the transition ends: the optimistic overlay is dropped and the new todos prop (which now contains "Buy milk") renders. The log has one change at 1,025 ms from "(saving…)" to the real item; there's no frame where both or neither are shown.
  5. For "This will fail", insertTodo throws, the action catches it and returns { error } (so no error boundary), and doesn't call refresh(). The transition ends with the real list unchanged, so the optimistic item vanishes and the alert appears, in a single change at 1,013 ms. No undo code anywhere.
  6. setError runs after an await, so it's wrapped in startTransition: React doesn't yet treat state updates after an await as part of the surrounding action, and without the wrapper the alert could commit separately from the list update.
AdvancedThe rest of the todos app

The store (with its slow, sometimes failing write), the action, the page and the route table:

lib/todos.ts
import "server-only";

export type Todo = { id: number; text: string };

const store = globalThis as unknown as { todos?: Todo[] };
const todos = (store.todos ??= [{ id: 1, text: "Write the chapter" }]);

export async function listTodos(): Promise<Todo[]> {
  return todos.map((t) => ({ ...t }));
}

export async function insertTodo(text: string): Promise<Todo> {
  await new Promise((r) => setTimeout(r, 1000)); // a slow write
  if (text.includes("fail")) throw new Error("Write rejected");
  const todo = { id: todos.length + 1, text };
  todos.push(todo);
  return todo;
}
app/actions.ts
"use server";

import { refresh } from "next/cache";
import { insertTodo } from "@/lib/todos";

export async function addTodo(text: string): Promise<{ error?: string }> {
  try {
    await insertTodo(text);
  } catch {
    return { error: `Couldn't save “${text}”` };
  }
  refresh(); // the response carries the re-rendered page with the real list
  return {};
}
app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { listTodos } from "@/lib/todos";
import { TodoList } from "./todo-list";

async function Todos() {
  await connection();
  return <TodoList todos={await listTodos()} />;
}

export default function Page() {
  return (
    <main>
      <h1>Todos</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <Todos />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ◐ /
└ ○ /_not-found


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedThings to get right
  • Keys for temporary items, made outside the reducer. The optimistic todo gets a temporary negative ID from a counter in add. When the real one arrives with its own ID, React unmounts the temporary row and mounts the real one. If you want the row to keep its DOM (focus, animation), generate a client ID (crypto.randomUUID()), send it to the action, and store it as the real ID.
  • Only inside an action or transition. Calling the setter from a plain onClick without startTransition makes React warn ("An optimistic state update occurred outside a transition or action…") and the value doesn't stick. A <form action={fn}> handler, an action prop, or startTransition(async () => …) all count.
  • The reducer re-runs, so keep it pure. If the real todos change while your action is pending (another request refreshed the page), or React simply re-renders with several updates pending, it re-applies every pending change on top of the current list, which is why the update is a reducer and not a fixed value. Anything the reducer computes for itself (Date.now(), Math.random()) is computed again on every re-run.
  • Overlapping submissions: what an impure reducer did. Next.js dispatches Server Actions one at a time per client (see Server Actions vs Route Handlers), so three quick submissions are three pending changes for up to 3 s. A first version of this component made the temporary ID inside the reducer ({ id: -Date.now(), text, saving: true }). Submitting "one", "two" and "three" 100 ms apart saved all three, but at 210 ms the list showed one (saving…) | one (saving…) | two (saving…) | three (saving…), and after the last action it kept one (saving…) | one (saving…) | two (saving…) above the real one | two | three, 10 s later still. Printing each row's key showed why: the re-run computed all three pending rows in the same millisecond, so they had the same key (-1792955343937), and React's reconciliation with duplicate keys left orphaned DOM rows behind (a production build prints no duplicate-key warning). With the ID made in add, as above, the same test gave one (saving…) | two (saving…) | three (saving…) at 213 ms and exactly one | two | three at 3,043 ms, twice (React 19.3.0, Next.js 16.4.3). Note that nothing changed at 1 s or 2 s, when the first two actions had finished: the optimistic rows and the real list switched in one commit when the last queued action finished.
  • Use it for writes that almost always succeed: likes, toggles, adding to a list, renaming, reordering. Don't use it where the server decides the outcome (payments, seat bookings, anything with an ID the next screen needs): show a pending state instead.
AdvancedBefore React 19

#Revalidating and redirecting after a mutation

Added

Writing to the database is half a mutation. The other half is making every place that shows that data stop showing the old version: the server's caches ('use cache' results, prerendered pages) and the client's router cache. Next.js won't guess which cached data your write affected. You tell it, from inside the action, with one of four functions from next/cache, and you choose by asking two questions: what is stale (tagged data, a whole route, or nothing cached at all) and who must see the change, and when (this user, right now; or everyone, eventually).

FunctionWhat it doesThe action's own responseTypical use
updateTag(tag)Expires everything tagged tag now; the next read waits for fresh dataRe-rendered page with fresh dataThe user must see their own write (read-your-own-writes). Server Actions only.
revalidateTag(tag, 'max')Marks the tag stale; the next read gets the old value and triggers a background refreshNo re-renderContent where a short delay is fine (a CMS, a catalogue); also usable in Route Handlers (webhooks)
revalidatePath(path)Invalidates what one route rendered (or a layout and everything below it with 'layout')Re-rendered pageOne page is affected and you haven't tagged the data
refresh()Invalidates nothing; re-renders the current pageRe-rendered page; cached parts unchangedThe page shows uncached, per-request data (cookies, a live query) that the action changed. Server Actions only.

The mental model: the action's response is a free re-render of the page the user is on, and these functions decide whether that re-render happens and whether it may use cached data. redirect() is the fifth tool: it ends the action and renders a different page in the same response, so anything you want that page to see fresh must be invalidated before you redirect. The caching model itself is chapter 5's subject (cacheTag and revalidating on demand); this topic is about calling these functions from mutations and what each one does to the response.

The app below has a cached list (getPosts, tagged "posts") and an uncached live count on the same page, and one button per function. Each button adds a post and then calls its function.

Below: the actions, then what happened when each of the four buttons on / was clicked once in headless Chrome, on a fresh copy of the build and a fresh next start (so every run starts from "1 posts"). "Same tab" is what the page showed after the action; the two GETs are new requests for / made afterwards, one second apart.

app/actions.ts
"use server";

import { redirect } from "next/navigation";
import { refresh, revalidatePath, revalidateTag, updateTag } from "next/cache";
import { insertPost } from "@/lib/posts";

export async function withUpdateTag() {
  await insertPost("Added with updateTag");
  updateTag("posts"); // expire now; this response re-renders with fresh data
}

export async function withRevalidateTag() {
  await insertPost("Added with revalidateTag");
  revalidateTag("posts", "max"); // mark stale; refresh in the background on the next read
}

export async function withRevalidatePath() {
  await insertPost("Added with revalidatePath");
  revalidatePath("/"); // invalidate everything the "/" page rendered
}

export async function withRefreshOnly() {
  await insertPost("Added with refresh only");
  refresh(); // re-render the page, but the cached list is still the old entry
}

export async function createAndRedirect() {
  await insertPost("Added before a redirect");
  updateTag("posts"); // first: invalidate
  redirect("/"); // then: navigate (this throws, nothing after it runs)
}

export async function redirectTooEarly() {
  await insertPost("Added, redirect too early");
  redirect("/");
  updateTag("posts"); // never runs
}

export async function redirectInsideTry() {
  try {
    await insertPost("Added inside try");
    updateTag("posts");
    redirect("/"); // throws NEXT_REDIRECT…
  } catch (error) {
    console.log(`[redirectInsideTry] caught: ${(error as Error).message}`); // …and this catches it
  }
}
Result
button          x-action-revalidated  getPosts ran     same tab after the action              next GET   GET 1 s later
refresh         2                     no               1 posts / Database has 2 posts         1 posts    1 posts
revalidateTag   (none)                after 1st GET    1 posts / Database has 1 posts         1 posts    2 posts
updateTag       1                     in the action    2 posts / Database has 2 posts         2 posts    2 posts
revalidatePath  1                     in the action    2 posts / Database has 2 posts         2 posts    2 posts
What’s happening
  1. getPosts is a 'use cache' function tagged "posts", so the list and the heading are part of the prerendered static shell (with the default cache profile, the build shows Revalidate 15m, Expire 1y). LiveCount awaits connection(), so it's a dynamic hole rendered per request: it always reads the store directly. That split is what lets the table tell the four functions apart.
  2. refresh(): the response re-rendered the page (x-action-revalidated: 2, about 2.5 KB on the wire), so the live count went to 2. But nothing was invalidated, so the cached list still said "1 posts", in the tab and for every later visitor. refresh is for data that isn't cached.
  3. revalidateTag("posts", "max"): the response carried only the return value (no x-action-revalidated; the whole body was 0:{"a":"$@1",…} and 1:"$undefined"), so the tab didn't change at all, not even the live count. The tag was marked stale: the next GET still served "1 posts" and started a background refresh (the log printed [getPosts] read the database: 2 post(s) after it), so the GET one second later showed "2 posts". That's stale-while-revalidate.
  4. updateTag("posts"): the tag expired immediately, getPosts ran again inside the action's request (the log line appeared during the action), and the re-rendered page in the response showed "2 posts". Every later GET saw 2 as well. This is read-your-own-writes in one round trip.
  5. revalidatePath("/"): same visible result as updateTag here, because / is the only page that uses the list. The difference shows up with a second page that also reads getPosts: per the revalidatePath docs, revalidatePath("/") only invalidates /, while updateTag("posts") reaches every page that used the tag (this app has no second page, so that part wasn't observed).
AdvancedThe rest of the posts app

The cached getPosts and the uncached countPosts, the page with its buttons, the /new page and the route table:

lib/posts.ts
import "server-only";
import { cacheTag } from "next/cache";

const store = globalThis as unknown as { posts?: string[] };
const posts = (store.posts ??= ["Hello world"]);

export async function getPosts() {
  "use cache";
  cacheTag("posts");
  console.log(`[getPosts] read the database: ${posts.length} post(s)`);
  return [...posts];
}

export async function countPosts() {
  return posts.length; // not cached: read on every render that asks
}

export async function insertPost(title: string) {
  posts.push(title);
}
app/page.tsx
import { Suspense } from "react";
import { connection } from "next/server";
import { countPosts, getPosts } from "@/lib/posts";
import { withRefreshOnly, withRevalidatePath, withRevalidateTag, withUpdateTag } from "./actions";

async function LiveCount() {
  await connection(); // dynamic: rendered per request, never cached
  return <p id="live">Database has {await countPosts()} posts right now</p>;
}

export default async function Page() {
  const posts = await getPosts(); // cached with the "posts" tag
  return (
    <main>
      <h1>{posts.length} posts</h1>
      <Suspense fallback={<p>…</p>}>
        <LiveCount />
      </Suspense>
      <ol>
        {posts.map((p, i) => (
          <li key={i}>{p}</li>
        ))}
      </ol>
      <form>
        <button formAction={withUpdateTag}>updateTag</button>
        <button formAction={withRevalidateTag}>revalidateTag</button>
        <button formAction={withRevalidatePath}>revalidatePath</button>
        <button formAction={withRefreshOnly}>refresh</button>
      </form>
    </main>
  );
}
app/new/page.tsx
import { createAndRedirect, redirectInsideTry, redirectTooEarly } from "../actions";

export default function NewPost() {
  return (
    <form>
      <button formAction={createAndRedirect}>Create, then redirect</button>
      <button formAction={redirectTooEarly}>Redirect too early</button>
      <button formAction={redirectInsideTry}>Redirect inside try</button>
    </form>
  );
}
next build
Route (app)      Revalidate  Expire
┌ ◐ /                   15m      1y
├ ○ /_not-found
└ ○ /new


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedThe stale read, seen with curl

The revalidateTag stale read, seen with curl -s -D - http://localhost:4507/ before the click and twice after it (headers filtered to x-nextjs-*):

Output
before   HTTP/1.1 200 OK  x-nextjs-stale-time: 300  x-nextjs-prerender: 1  x-nextjs-postponed: 1  <h1>1 posts</h1>
after    HTTP/1.1 200 OK  x-nextjs-stale-time: 300  x-nextjs-prerender: 1  x-nextjs-postponed: 1  <h1>1 posts</h1>
+1 s     HTTP/1.1 200 OK  x-nextjs-stale-time: 300                         x-nextjs-postponed: 1  <h1>2 posts</h1>

With updateTag instead, the first GET after the click was already <h1>2 posts</h1>. Note that x-nextjs-prerender: 1 is no longer sent once the page has been re-rendered after the invalidation.

AdvancedWhich one should I call?
  • The user who made the change must see it on the next screen: updateTag (or revalidatePath if you don't use tags). This is the normal case for forms.
  • Other people's views can lag a little, and you'd rather not make anyone wait: revalidateTag(tag, 'max'). It's also the one you call from a Route Handler (a CMS webhook), where updateTag and refresh throw.
  • The page shows per-request data that isn't cached, and the action changed it: refresh(). Setting or deleting a cookie with cookies() inside an action re-renders the page on its own (according to the docs; not exercised here).
  • Several kinds of data changed: call several. updateTag("posts") plus updateTag(\post-${id}\) is common.
  • Nothing visible changed (a "mark as read" ping): call nothing, and the response stays tiny.
AdvancedRedirecting after the write

redirect(url) from next/navigation throws a special error that ends the action and sends the user to url. With JavaScript, it's a client-side navigation in the same response; without it, a 303 See Other (see Forms with Server Actions). Three ways to write it, from /new, each clicked once in Chrome on a fresh server:

Output
button                  requests   x-action-redirect   lands on   page shows
Create, then redirect   POST /new  /;push              /          2 posts / Database has 2 posts right now
Redirect too early      POST /new  /;push              /          1 posts / Database has 2 posts right now
Redirect inside try     POST /new  (none)              /new       (stays on /new)
server log for the third: [redirectInsideTry] caught: NEXT_REDIRECT
What’s happening
  1. Create, then redirect: updateTag("posts") runs first, then redirect("/"). One POST does everything: the response says x-action-redirect: /;push and carries the payload for /, rendered after the tag expired, so the list shows the new post immediately. The log shows getPosts running during that request.
  2. Redirect too early: redirect("/") throws, so updateTag("posts") on the next line never runs. The post was saved (the live count says 2), but the cached list on / is still "1 posts", and stays that way for everyone until the cache entry is revalidated. TypeScript doesn't stop you: redirect returns never, and unreachable code isn't an error by default.
  3. Redirect inside try: redirect works by throwing an error whose message is NEXT_REDIRECT. The catch block caught it like any other error, logged it, and the action returned normally: no redirect header, the user stayed on /new. The same happens to notFound(), forbidden() and unauthorized().
  4. The fixes: call redirect after the try/catch, or rethrow Next.js's control-flow errors with unstable_rethrow(error) (from next/navigation, added in Next.js 15) as the first line of the catch.
AdvancedBefore Next.js 16
  • revalidateTag(tag) with one argument expired the tag immediately (what updateTag does now). In 16 the one-argument form is deprecated and a TypeScript error; pick updateTag(tag) in actions or revalidateTag(tag, 'max') for stale-while-revalidate.
  • Tags came from fetch: fetch(url, { next: { tags: ['posts'] } }), or unstable_cache(fn, keys, { tags }) for database calls. Both still work in 16; with Cache Components you tag with cacheTag inside 'use cache'.
  • revalidatePath('/', 'layout') was (and is) the sledgehammer: it invalidates every page under the root layout and purges the client router cache.
  • router.refresh() from next/navigation was the client-side way to re-fetch the current page after a mutation done through fetch. It still exists; refresh() from next/cache is the server-side equivalent inside an action.
  • If you meet a Next.js 14 app with odd stale data after an action redirects: Next.js 15's release notes list the fix "When redirecting from a Server Action, revalidations will now apply correctly".
Why is the new post missing?

An action on /new runs await insertPost(title); redirect("/"); updateTag("posts");. The user lands on /. Do they see their post? Does anyone?

Show answer

No, and nobody else does either until the cached list is revalidated some other way (with the default profile, it's considered for background revalidation after 15 minutes).

What’s happening
  1. insertPost saves the post: the database now has 2 posts.
  2. redirect("/") throws NEXT_REDIRECT. The action ends there; updateTag("posts") is never reached.
  3. Next.js renders / for the response. getPosts is cached under "posts" and nothing invalidated it, so the cached list with 1 post is used.
  4. Observed: the page showed "1 posts" next to the uncached "Database has 2 posts right now".
  5. The fix is to swap the two lines: invalidate, then redirect. With that order the same click landed on "2 posts".

#Securing Server Actions

Added

Everything earlier in this chapter points to one rule: a Server Action is a public HTTP endpoint, and it must defend itself the way an API route would. The convenience is real (no URL to design, typed arguments, one round trip), and it's also what makes actions easy to leave open: the function looks like a private call from your own component, so it's tempting to assume only your UI can reach it, with the arguments your UI would pass, as the user your page checked. None of those assumptions hold. The experiments above already showed an action run from curl with no cookies, on a path that returns 404, with a bound argument the page never rendered.

The mental model is a bank teller window that anyone can walk up to. The teller (your action) doesn't care which door you came in by (the page, the URL, the button). Every request has to show ID (authentication), be allowed to touch that account (authorisation), fill in the form correctly (validation), and gets back only the receipt it needs (shaped return values).

Next.js gives you a few protections for free: the Origin check against CSRF, a body size limit, encrypted closures, unguessable IDs that rotate, and removal of unused actions. None of them knows who your users are; checking that is your job, inside every action, ideally in a data access layer that every action goes through.

Below: the actions, then deleteNote (ID 40a77b8b75db2f23b739608dc49eb9d2d172229ce6, read from the build's action manifest) called with curl as different people. Each command was curl -s -X POST http://localhost:4507/ -H "Next-Action: 40a77b…" -H "Content-Type: text/plain;charset=UTF-8" [-H "Cookie: session=…"] --data '<args>'; each line is the action's result row.

app/actions.ts
"use server";

import { z } from "zod";
import { refresh } from "next/cache";
import { deleteAllNotes, deleteNoteAsCurrentUser } from "@/lib/notes";

const NoteId = z.string().regex(/^n\d+$/);

export async function deleteNote(noteId: unknown) {
  const id = NoteId.safeParse(noteId); // arguments are untrusted input, whatever the TypeScript type says
  if (!id.success) return { error: "Bad note id" };
  const result = await deleteNoteAsCurrentUser(id.data);
  if (result !== "deleted") return { error: result };
  refresh();
  return { ok: true };
}

// ❌ Relies on the /admin page's check. Anyone who has the action ID can call it.
export async function purgeAllUnsafe() {
  await deleteAllNotes();
  console.log("[purgeAllUnsafe] all notes deleted");
}
Result
no cookie,    args ["n1"]            1:{"error":"unauthenticated"}
bob-session,  args ["n1"]            1:{"error":"forbidden"}
bob-session,  args [{"$ne":null}]    1:{"error":"Bad note id"}
bob-session,  args ["n2"]            1:{"ok":true}
What’s happening
  1. Nobody is signed in: deleteNoteAsCurrentUser reads the session cookie through getSession(), finds none, and returns "unauthenticated". The action passes it on as an error. Authentication happens inside the action's call path, not in the page.
  2. Bob is signed in, but n1 belongs to Alice. The DAL loads the note and compares ownerId with the session's userId: "forbidden". This is authorisation, the check people forget: being logged in doesn't make every ID yours (an IDOR bug, "insecure direct object reference").
  3. Bob sends an object instead of an ID, the kind of payload that probes for NoSQL injection. The action's parameter is typed unknown on purpose: TypeScript types are erased at runtime, and the wire format can carry any serialisable value. NoteId.safeParse rejects it before it reaches the data layer.
  4. Bob deletes his own note: every check passes, the note is removed, refresh() re-renders the page, and the action returns { ok: true }, not the deleted row. Return only what the UI needs: the return value is serialised to whoever called, attacker included.
  5. Notice where the checks live: in lib/notes.ts, a server-only module that every caller goes through. A second action, a Route Handler or a page that deletes notes gets the same checks for free. The Next.js docs call this the data access layer; see Data security: the Data Access Layer and Authorisation close to the data.
AdvancedThe rest of the notes app

The config with the Server Actions options, the session and data layer, the delete button, the two pages and the route table:

next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
  experimental: {
    serverActions: {
      allowedOrigins: ["admin.example.com"], // a proxy host whose Origin differs from ours
      bodySizeLimit: "100kb", // default is 1MB
    },
  },
};

export default nextConfig;
lib/session.ts
import "server-only";
import { cookies } from "next/headers";

// Stand-in for a real session store (in a real app: a signed, httpOnly cookie checked against a database).
const sessions: Record<string, { userId: string; role: "user" | "admin" }> = {
  "alice-session": { userId: "alice", role: "user" },
  "bob-session": { userId: "bob", role: "user" },
  "root-session": { userId: "root", role: "admin" },
};

export async function getSession() {
  const token = (await cookies()).get("session")?.value;
  return token ? (sessions[token] ?? null) : null;
}
lib/notes.ts
import "server-only";
import { getSession } from "./session";

type Note = { id: string; ownerId: string; text: string };
const store = globalThis as unknown as { notes?: Note[] };
const notes = (store.notes ??= [
  { id: "n1", ownerId: "alice", text: "Alice's secret plan" },
  { id: "n2", ownerId: "bob", text: "Bob's shopping list" },
]);

export async function notesFor(userId: string) {
  return notes.filter((n) => n.ownerId === userId).map(({ id, text }) => ({ id, text }));
}

export async function allNotes() {
  return notes.map(({ id, text }) => ({ id, text }));
}

// The data access layer: every mutation checks who is asking, here, next to the data.
export async function deleteNoteAsCurrentUser(noteId: string): Promise<"deleted" | "unauthenticated" | "forbidden" | "missing"> {
  const session = await getSession();
  if (!session) return "unauthenticated";
  const i = notes.findIndex((n) => n.id === noteId);
  if (i < 0) return "missing";
  if (notes[i].ownerId !== session.userId && session.role !== "admin") return "forbidden";
  notes.splice(i, 1);
  return "deleted";
}

export async function deleteAllNotes() {
  notes.length = 0;
}
app/delete-button.tsx
"use client";

import { useTransition } from "react";
import { deleteNote } from "./actions";

export function DeleteButton({ id }: { id: string }) {
  const [pending, startTransition] = useTransition();
  return (
    <button disabled={pending} onClick={() => startTransition(async () => void (await deleteNote(id)))}>
      Delete
    </button>
  );
}
app/page.tsx
import { Suspense } from "react";
import { getSession } from "@/lib/session";
import { notesFor } from "@/lib/notes";
import { DeleteButton } from "./delete-button";

async function MyNotes() {
  const session = await getSession();
  if (!session) return <p>Signed out.</p>;
  const notes = await notesFor(session.userId);
  return (
    <ul>
      {notes.map((n) => (
        <li key={n.id}>
          {n.text} <DeleteButton id={n.id} />
        </li>
      ))}
    </ul>
  );
}

export default function Page() {
  return (
    <main>
      <h1>My notes</h1>
      <Suspense fallback={<p>Loading…</p>}>
        <MyNotes />
      </Suspense>
    </main>
  );
}
app/admin/page.tsx
import { Suspense } from "react";
import { redirect } from "next/navigation";
import { getSession } from "@/lib/session";
import { allNotes } from "@/lib/notes";
import { purgeAllUnsafe } from "../actions";

async function AdminPanel() {
  const session = await getSession();
  if (session?.role !== "admin") redirect("/"); // guards the page, not the action
  return (
    <>
      <p>{(await allNotes()).length} notes in total</p>
      <form action={purgeAllUnsafe}>
        <button>Delete every note</button>
      </form>
    </>
  );
}

export default function AdminPage() {
  return (
    <main>
      <h1>Admin</h1>
      <Suspense fallback={<p>Checking…</p>}>
        <AdminPanel />
      </Suspense>
    </main>
  );
}
next build
Route (app)
┌ ◐ /
├ ○ /_not-found
└ ◐ /admin


○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
AdvancedA guarded page doesn't guard its actions

/admin checks the role and redirects everyone else, and its form calls purgeAllUnsafe, which checks nothing because "only admins can see that button". Requesting the page as Bob shows the redirect doing its job:

Output
curl -s -H "Cookie: session=bob-session" http://localhost:4507/admin
  status 200, no admin panel; the streamed part carries
  <meta id="__next-page-redirect" http-equiv="refresh" content="1;url=/"/> and NEXT_REDIRECT;replace;/;307;

(The status is 200 rather than 307 because the redirect happens inside a Suspense boundary, after the static shell has started streaming; Next.js falls back to a meta refresh and a client-side redirect.) Now the action itself, with no cookie at all, posted to / rather than /admin; its ID came from the build manifest (003bc0fc67cd3e0801c8144fd63bcae2a01599a0ee), and anyone who has ever seen the admin page's HTML has it too:

Output
as root:          2 notes in total
anonymous POST /  with Next-Action: 003bc0…a0ee, body []
                  HTTP/1.1 200 OK
                  0:{"a":"$@1","q":"","i":false,"b":"Oinw0hKJlDp6EbGHnlfXP"}
                  1:"$undefined"
server log:       [purgeAllUnsafe] all notes deleted
as root again:    0 notes in total
What’s happening
  1. The page-level check controls what HTML Bob gets. It runs when /admin renders, and it's correct as far as it goes.
  2. The action is a separate entry point. Calling it doesn't render /admin, so the page's check never runs; and as the guestbook showed, the request doesn't even have to be sent to /admin.
  3. The action ID isn't a secret. It's in the HTML of every page that renders the form and in the JavaScript of every client component that imports the action, and it only changes when you rebuild. "Nobody knows the ID" is security by obscurity, and Next.js's docs say to treat every action as reachable by direct POST.
  4. The fix is one line at the top of purgeAllUnsafe: const s = await getSession(); if (s?.role !== "admin") throw new Error("Forbidden");, or better, move the purge into the DAL next to the role check. Delete-everything operations also deserve stronger handling (re-authentication, an audit log).
AdvancedCSRF and the Origin check

A cross-site request forgery is another site making the victim's browser send a request to yours, with the victim's cookies attached. Server Actions have three layers against it: they only accept POST; session cookies set with SameSite=Lax (Chrome's default when the attribute is missing; set it explicitly) aren't sent on cross-site POSTs; and Next.js compares the request's Origin header with the Host (or X-Forwarded-Host) and aborts on a mismatch. The same deleteNote call with different Origin headers:

Output
Origin: https://evil.example         HTTP/1.1 500   1:E{"digest":"3304071381"}
Origin: https://admin.example.com    HTTP/1.1 200   1:{"error":"unauthenticated"}
Origin: http://localhost:4507        HTTP/1.1 200   1:{"error":"unauthenticated"}
server log for the first one:
  `x-forwarded-host` header with value `localhost:4507` does not match `origin` header with value
  `evil.example` from a forwarded Server Actions request. Aborting the action.
  ⨯ Error: Invalid Server Actions request.
What’s happening
  1. evil.example doesn't match the host, so Next.js aborts before running the action: a 500 with only a digest for the client, and the reason in the server log.
  2. admin.example.com is listed in serverActions.allowedOrigins in next.config.ts, so it's accepted, and the action ran (and refused, because there was no session). You need that list when a proxy or CDN serves your app under a host the Next.js server doesn't see in Host/X-Forwarded-Host. Entries match the Origin's host (with port), and support * for one subdomain label and ** for several.
  3. The page's own origin passes, as it does for every request your UI makes.
  4. A request with no Origin header at all (curl's default, and the first experiments in this chapter) is let through: the docs say "with a warning", though no warning appeared in the next start log here. Browsers always send Origin on a cross-site POST, so this gap doesn't help a CSRF attacker; it does mean scripts and servers can call your actions freely, which is the authentication point again.
AdvancedBody size limit

Server Action request bodies are limited to 1 MB by default; this app lowers it to 100 KB. A 150 KB argument:

Output
HTTP/1.1 500 Internal Server Error
0:{"a":"$@1","q":"","i":false,"b":"Oinw0hKJlDp6EbGHnlfXP"}
1:E{"digest":"2531974433"}
server log: ⨯ Error: Body exceeded 100kb limit.
            To configure the body size limit for Server Actions, see: https://nextjs.org/docs/app/api-reference/next-config-js/serverActions#bodysizelimit
              statusCode: 413,

Raise bodySizeLimit (bytes or strings like '2mb') only for the actions that need it, knowing the limit applies to the raw body including multipart overhead. Large uploads are usually better sent straight to object storage with a presigned URL than through an action.

AdvancedThe checklist
  1. Authenticate in every action (or in the DAL it calls): read the session from cookies, never from an argument.
  2. Authorise the specific resource: "may this user change this note?"; look it up by ID and owner, don't trust an owner field from the client.
  3. Validate every argument with a schema, typed unknown at the boundary: form fields, bound arguments, prevState, closures you didn't write yourself.
  4. Return minimal, shaped data; never whole database rows. (With experimental.taint: true in next.config, React's experimental_taintObjectReference and experimental_taintUniqueValue refuse to send marked objects or values to the client: a second layer, not a replacement for shaping.)
  5. Rate-limit expensive actions (sending email, AI calls) per user or IP.
  6. Keep the framework protections on: don't widen allowedOrigins beyond your proxies, keep bodySizeLimit modest, set a stable NEXT_SERVER_ACTIONS_ENCRYPTION_KEY when you run several instances.
  7. Don't rely on route-level gates: not the page's check, not proxy.ts (formerly middleware), not a CDN rule. An action posted to any path runs, and proxy is an optimistic first check at best; the 2025 middleware-bypass vulnerability (CVE-2025-29927) is the classic example of why authorisation must not live only there (proxy.ts (formerly middleware), Security checklist for a Next.js app).
Is the admin action safe?

app/admin/page.tsx redirects anyone who isn't an admin. It renders <form action={purgeAllUnsafe}>, and purgeAllUnsafe has no checks. The app is deployed; a signed-out user has an old copy of the admin page's HTML. Can they delete every note?

Show answer

Yes.

What’s happening
  1. The old HTML contains the form's hidden $ACTION_ID_003bc0… input, which is all a POST needs (a JavaScript call needs the same ID in a Next-Action header).
  2. If the build hasn't changed since, the ID is still valid. Sending it to any path, with no cookie, runs the action: observed above, "2 notes in total" became "0 notes in total".
  3. The page's redirect never runs, because the action request doesn't render /admin.
  4. The Origin check doesn't stop it: a script or curl sends no Origin (allowed), or sets the right one. It only stops browsers on other sites from doing it on a victim's behalf.
  5. The only fix is a check inside the action or in the data layer it calls.

#Server Actions vs Route Handlers

Added

Both run your code on the server in response to an HTTP request, so the question comes up in every App Router codebase and most interviews: "why not just use actions for everything?" The short answer is that they're designed for different callers. A Server Action is an RPC between your React UI and your server: the URL, method and wire format are private details chosen by the framework, the call is tied to React transitions and the router, and its response can carry re-rendered UI. A Route Handler (app/**/route.ts) is a normal HTTP endpoint: you choose the URL, the methods, the status codes, the headers and the body format, so any client can use it, and GET responses can be cached by browsers and CDNs.

The mental model: an action is an internal phone extension; a Route Handler is the company's public phone number. Colleagues (your components) use the extension because it's quicker and they know who's on the other end. Customers, partners and machines (mobile apps, webhooks, other services, curl scripts you document) need a number that's stable, documented and behaves like every other number.

There's also a behavioural difference that surprises people. Next.js dispatches Server Actions one at a time per client: while one is in flight, the next one waits. That keeps the re-rendered page consistent with the action that produced it, but it means actions can't run in parallel from the browser. The app below measures it.

Below: the client component that races them, then what it printed for three clicks on "Run" in headless Chrome.

app/race.tsx
"use client";

import { useState } from "react";
import { slowAction } from "./actions";

type Run = { label: string; start: number; end: number };

export function Race() {
  const [result, setResult] = useState("");

  async function run() {
    let t = performance.now();
    const viaActions: Run[] = await Promise.all(["a", "b", "c"].map((l) => slowAction(l)));
    const actionsMs = Math.round(performance.now() - t);

    t = performance.now();
    const viaRoute: Run[] = await Promise.all(
      ["a", "b", "c"].map((label) =>
        fetch("/api/slow", { method: "POST", body: JSON.stringify({ label }) }).then((r) => r.json()),
      ),
    );
    const routeMs = Math.round(performance.now() - t);

    const starts = (runs: Run[]) => runs.map((r) => r.start - runs[0].start).join(", ");
    setResult(
      `3 actions: ${actionsMs} ms (server start offsets ${starts(viaActions)} ms)\n` +
        `3 route handler calls: ${routeMs} ms (server start offsets ${starts(viaRoute)} ms)`,
    );
  }

  return (
    <>
      <button onClick={run}>Run</button>
      <pre>{result}</pre>
    </>
  );
}
Result
3 actions: 1522 ms (server start offsets 0, 505, 1010 ms)
3 route handler calls: 507 ms (server start offsets 0, 1, 1 ms)

3 actions: 1516 ms (server start offsets 0, 505, 1011 ms)
3 route handler calls: 508 ms (server start offsets 0, 2, 3 ms)

3 actions: 1517 ms (server start offsets 0, 504, 1011 ms)
3 route handler calls: 506 ms (server start offsets 0, 1, 2 ms)
What’s happening
  1. Both paths call the same slowWork, which waits 500 ms and reports, in milliseconds since the server module loaded, when it started and finished. The page subtracts the first start time, so "0, 505, 1010" means the second call started 505 ms after the first.
  2. Promise.all over three slowAction calls starts three calls in the same tick, but Next.js's client queues them: the second POST is only sent when the first response has arrived. The server saw them 505 ms apart, and the whole thing took about 1.5 s, three times the work.
  3. The three fetch calls to /api/slow went out together, reached the server within 1 to 3 ms of each other, ran concurrently, and all finished in about 0.5 s.
  4. The queueing happens in the browser, not the server: Next.js's docs note that the server runs each action request as it arrives. Two different users' actions run in parallel as usual.
  5. /api/slow is ƒ (dynamic): a POST handler always runs per request. The page is ○ because Race only does work after a click.
AdvancedThe rest of the timing app

The shared slow function, the action, the Route Handler, the page and the route table:

lib/slow.ts
import "server-only";

const t0 = Date.now();

// Pretend work: waits 500 ms and reports when it started and ended on the server.
export async function slowWork(label: string) {
  const start = Date.now() - t0;
  await new Promise((r) => setTimeout(r, 500));
  return { label, start, end: Date.now() - t0 };
}
app/actions.ts
"use server";

import { slowWork } from "@/lib/slow";

export async function slowAction(label: string) {
  return slowWork(label);
}
app/api/slow/route.ts
import { slowWork } from "@/lib/slow";

export async function POST(request: Request) {
  const { label } = await request.json();
  return Response.json(await slowWork(label));
}
app/page.tsx
import { Race } from "./race";

export default function Page() {
  return (
    <main>
      <h1>Three calls in parallel?</h1>
      <Race />
    </main>
  );
}
next build
Route (app)
┌ ○ /
├ ○ /_not-found
└ ƒ /api/slow


○  (Static)   prerendered as static content
ƒ  (Dynamic)  server-rendered on demand
AdvancedChoosing
You need…UseWhy
A form or button in your app that changes dataServer ActionProgressive enhancement, useActionState, one round trip with the re-rendered page, updateTag/refresh
Data for your own Server ComponentsNeither: fetch or query directly in the componentA self-request adds a network hop, and at build time there's no server to call
A public or versioned API (mobile app, partners, other services)Route HandlerStable URL, your choice of methods, status codes, JSON shape and auth (API keys, bearer tokens, CORS)
A webhook receiver (Stripe, a CMS)Route HandlerThird parties call a URL; you need the raw body to verify the signature; you return the status they expect; revalidateTag works there
A GET that browsers or a CDN should cache, or a file download, RSS, an imageRoute HandlerActions are POST only, never cached
Streaming a response (SSE, AI tokens, a large export)Route HandlerYou control the Response stream and headers
Several independent calls in parallel from the clientRoute Handlers, or one action that does the work in parallel on the serverActions from one client run one at a time
Reading data on the client after the page loaded (polling, infinite scroll)Route Handler with SWR or TanStack QueryCacheable GETs, parallel requests; actions would queue and can't be cached
AdvancedThe same mutation, both ways

For a mutation your own UI makes, the action version is shorter and gives you more. The Route Handler version of the guestbook's sign form would be a route.ts with export async function POST(request: Request) that parses await request.formData() or await request.json(), plus a client component with onSubmit, preventDefault, fetch('/api/guestbook', { method: 'POST', body }), its own loading flag, then router.refresh() to re-render the page. Three things are lost along the way: it no longer works without JavaScript; the refresh is a second request; and updateTag and refresh from next/cache aren't available (only revalidateTag and revalidatePath). What you gain is a real URL others can call and document. (This fragment wasn't built as an app; the chapter on Route Handlers has runnable ones.)

Both need exactly the same security: authenticate, authorise, validate, shape the response. A Route Handler doesn't get the Origin check: curl -X POST -H "Origin: https://evil.example" to /api/slow got HTTP/1.1 200 OK and a normal result. So a cookie-authenticated POST handler needs its own CSRF defence (SameSite cookies, checking Origin yourself, or a CSRF token).

Three actions in Promise.all

A client component runs await Promise.all([save(a), save(b), save(c)]), where save is a Server Action that takes 500 ms on the server. Roughly how long does the await take, and why?

Show answer

About 1.5 seconds.

What’s happening
  1. Promise.all calls all three stubs immediately, so three action calls are requested at once.
  2. Next.js's client dispatcher sends Server Actions one at a time: save(b)'s POST waits until save(a)'s response has been handled, then save(c) waits for save(b).
  3. Each takes about 500 ms on the server, so they finish at about 0.5, 1.0 and 1.5 s. Measured: 1,516 to 1,522 ms, with server start times 505 and 1,010 ms after the first.
  4. To parallelise, make one action that does the three writes with Promise.all on the server, or call a Route Handler (three fetch calls took about 507 ms in the same test).

Written fresh for interviews (there was no Google Doc for this one). Examples target Next.js 16.4.3 and React 19.3 and were built with next build by scripts/check-next.mjs.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.