Skip to the chapter
←ReactGGroundwork⚛React
React/Advanced/A5
38 / 57 written chapters
/

The cover
  1. B1Setup & JSX
  2. B2Thinking in React
  3. B3Components
  4. B4Props
  5. B5useState
  6. B6Events & conditionals
  7. B7Lists & keys
  8. B8Forms
  9. B9useEffect
  10. B10Lifting state & styling
  11. B11Guided project
  12. ★Cheat page
  1. I1useRef
  2. I2Context
  3. I3useReducer
  4. I4Custom hooks
  5. I5Effects in depth
  6. I6Routing
  7. I7State architecture
  8. I8Memoisation
  9. I9Boundaries & portals
  10. I10Data fetching
  11. I11Forms at scale
  12. I12State libraries
  13. I13Auth
  14. I14TanStack Query
  15. I15Testing
  16. I16TypeScript
  17. I17Animation
  18. I18E2E testing
  19. I19i18n
  20. I20File uploads
  21. I21Real-time UI
  22. I22shadcn/ui
  23. ★Cheat page
  1. A1Fiber
  2. A2Concurrent
  3. A3Suspense
  4. A4Server Components
  5. A5Server Actions
  6. A6use()
  7. A7Rendering strategies
  8. A8Effect timing
  9. A9External stores
  10. A10Refs in depth
  11. A11Component patterns
  12. A12Performance
  13. A13Accessibility
  14. A14Design systems
  15. A15Advanced TypeScript
  16. A16Architecture at scale
  17. A17Custom renderers
  18. A18Migrating & upgrading
  19. A19Production debugging
  20. A20Review & judgement
  21. A21State machines
  22. A22Feature flags
  23. A23Interview bank
  24. ★Cheat page

⚛A5 · Part 3 · Advanced

Server Actions

A function that runs on the server, called from the client, with no API route in between.

6 min read10 sections2 exercises
38 of 57

In this chapter

  • The shape
  • What the framework does for you, and what it does not
  • Progressive enhancement
  • Pending state and results
  • useFormStatus, and its one rule

and 5 more

about 6 min
10On this page
  1. The shape
  2. What the framework does for you, and what it does not
  3. Progressive enhancement
  4. Pending state and results
  5. useFormStatus, and its one rule
  6. Optimistic UI
  7. Revalidation
  8. Actions outside forms
  9. After a deploy
  10. When not to use one

The shape

"use server";

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  await db.post.create({ data: { title } });
  revalidatePath("/posts");        // refresh what changed
}
import { createPost } from "./actions";

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button>Create</button>
    </form>
  );
}

No fetch, no route handler, no JSON, no manual serialisation. "use server" makes the function callable from the client; the framework generates the endpoint and the client passes a reference to it. React's own name for these is Server Functions; "Server Action" is the name for one used as a form action or called inside a transition.

Under the hood it is a POST. At build time the client bundle gets an encrypted action ID and a small dispatcher in place of the function body; the body never leaves the server.

This is a public HTTP endpoint

The most important thing to understand. Marking a function "use server" exposes it to the internet — anyone can call it with any arguments, from anywhere. Being imported by one form does not protect it, and neither does rendering that form only for signed-in users. Every action must authenticate and authorise for itself, exactly like a REST route would, and validate its input.

"use server";

export async function deletePost(rawId: unknown) {
  const id = z.string().uuid().parse(rawId);               // shape, first

  const session = await auth();
  if (!session) throw new Error("Unauthorised");           // who

  const post = await db.post.findUnique({ where: { id } });
  if (!post || post.authorId !== session.userId) {
    throw new Error("Forbidden");                          // what
  }

  await db.post.delete({ where: { id } });
  return { ok: true };                                     // not the deleted record
}

The order is deliberate: reject malformed input before touching the database, and check ownership of the specific record, not just that someone is logged in. The return value is serialised to the client too, so return what the UI needs rather than a raw database row that may carry fields nobody should see.

What the framework does for you, and what it does not

Next.js handlesYou still handle
CSRF: the request's Origin must match the host, or serverActions.allowedOriginsAuthentication and authorisation in every action
A 1 MB request body limit, raised with serverActions.bodySizeLimitValidating every field, since FormData is untrusted
Encrypted action IDs, and unused actions removed from the bundleRate limiting anything expensive or abusable
Encrypting values captured by an inline action's closureShaping return values to what the UI renders

The protocol itself is also attack surface. In December 2025 a flaw in how React decodes Server Function requests allowed remote code execution before any of your checks ran — see React2Shell. Keeping React and the framework patched is part of shipping actions.

Progressive enhancement

A <form action={fn}> works before the JavaScript has loaded, and with JavaScript disabled. The browser posts the form the ordinary way; the framework routes it to the same function. Once hydrated, the same form submits without a page reload. You write it once and it degrades correctly.

Pending state and results

const [state, formAction, isPending] = useActionState(createPost, null);

<form action={formAction}>
  <input name="title" />
  <button disabled={isPending}>{isPending ? "Saving…" : "Create"}</button>
  {state?.error && <p role="alert">{state.error}</p>}
</form>

The action receives the previous state as its first argument and returns the next one, so validation errors come back as data rather than as thrown exceptions — which means they survive the non-JavaScript path too.

export async function createPost(prev, formData) {
  const parsed = schema.safeParse(Object.fromEntries(formData));
  if (!parsed.success) return { error: parsed.error.issues[0].message };
  await db.post.create({ data: parsed.data });
  redirect("/posts");
}

Three behaviours worth knowing. Calls are queued: submit twice and the second call runs after the first, receiving its result as prev. A form using it resets after a successful submit, so return the submitted values if a failed form should keep them. And if the action throws, React cancels the queued calls and shows the nearest error boundary — so expected failures should be returned, not thrown.

The optional third argument, a permalink, is for the moment before JavaScript loads: the browser navigates there after submitting, and the same form must be rendered on that page to receive the state.

useFormStatus, and its one rule

function SubmitButton() {
  const { pending } = useFormStatus();          // reads the form above
  return <button disabled={pending}>{pending ? "Saving…" : "Save"}</button>;
}

function NewPost() {
  const { pending } = useFormStatus();          // ✗ always false — the form is below
  return (
    <form action={createPost}>
      <SubmitButton />                           // ✓
    </form>
  );
}

useFormStatus reads the parent form of the component that calls it, never a form that component renders. That is exactly what makes a shared <SubmitButton /> work with no props, and exactly why calling it next to the <form> tag silently returns pending: false. It also exposes data, the FormData being submitted.

Optimistic UI

const [optimistic, addOptimistic] = useOptimistic(
  messages,
  (state, newMessage) => [...state, { ...newMessage, sending: true }]
);

async function send(formData) {
  addOptimistic({ text: formData.get("text") });   // appears instantly
  await sendMessage(formData);                     // then the real one arrives
}

React discards the optimistic state automatically once the action settles and the real data arrives — including on failure, where it simply disappears. No manual rollback, which is the part hand-rolled optimistic updates usually get wrong.

Revalidation

revalidatePath("/posts");            // this route's cache
updateTag("posts");                  // in an action: next read waits for fresh data
revalidateTag("posts", "max");       // elsewhere: serve stale, refresh in background

The mutation says what is now stale, and the framework refetches it. Same problem as query invalidation, same difficulty: getting the list of affected things right. Tags scale better than paths once several routes show the same data.

In Next.js 16, updateTag exists for read-your-own-writes: it can only be called in a Server Action, and the user who just created a post sees it rather than a cached list. revalidateTag now takes a second argument, a cache profile; the one-argument form is deprecated.

Actions outside forms

<button onClick={() => startTransition(() => deletePost(id))}>Delete</button>

An action is just an async function, so it can be called from a handler. Wrap it in a transition so the UI stays responsive and pending state is tracked. The same applies to the dispatch function from useActionState: a form's action prop wraps it in a transition for you, but calling it from onClick without startTransition logs an error in development.

After a deploy

Action IDs change between builds. A user who loaded the page before a deploy still holds the old IDs, and their next submit fails with "Failed to find Server Action". Treat that error as a prompt to refresh rather than a crash, and on self-hosted, multi-instance setups set NEXT_SERVER_ACTIONS_ENCRYPTION_KEY so every instance can decode the same references.

When not to use one

SituationBetter
A mobile app needs the same endpointA real API route — actions are framework-internal
Reading data on demandA Server Component, or a query. Actions run one at a time per client, so they make a poor data-fetching layer.
Long-running workA queue. An action holds a request open.
Streaming or websocketsNot what actions are for
Webhooks from a third partyA route handler, which has a stable URL
Interview answer, one sentence

"A Server Action is a server function callable from the client, with the endpoint and serialisation generated for you, and it progressively enhances because a form action works before hydration. The thing people miss is that it is a public endpoint — the framework adds CSRF and size limits, but the action must still authenticate, authorise, validate and shape its return value on its own, regardless of which component calls it."

02Practice this chapter

Opens in the editor: write it, run it, and check it against real tests.

  • 8 testsA form driven by useActionStateadvanced
  • 8 testsOptimistic messages with useOptimisticadvanced
All 54 for this level →
A5

Finished reading?

0 of 57 chapters read in this series

→Next stop

  1. A4Previous · A4Server Components
  2. A5you are here
  3. Next · A6The use() hookReading a promise or a context during render — and the only hook that breaks the rules.5 min read

01On this page

10 sections · 0%

  1. 01The shape
  2. 02What the framework does for you, and what it does not
  3. 03Progressive enhancement
  4. 04Pending state and results
  5. 05useFormStatus, and its one rule
  6. 06Optimistic UI
  7. 07Revalidation
  8. 08Actions outside forms
  9. 09After a deploy
  10. 10When not to use one

[] chapters · t top