Skip to content
intermediate Phase 6 · Server Actions and Forms

Defining Server Actions

Create mutation functions marked with use server.

50m
0 problems
Topic Progress 0%

'use server' at the Top: What the Directive Actually Guarantees

You have a mutation — create a project, archive an order, change a setting — and the reflex from years of SPA work is to write an API route, a fetch call, a loading flag, and a refetch. Server actions collapse that into a function. The directive marks it, the framework ships it, and the client calls it like any other function.

// app/projects/actions.ts
'use server';

export async function createProject(input: FormData) {
  const name = String(input.get('name') ?? '').trim();
  if (!name) return { error: 'Name is required' };

  const project = await db.project.create({ data: { name } });
  revalidatePath('/projects');
  return { id: project.id };
}

That file's exports are now server actions: functions whose implementation runs on the server, no matter where or how they are imported. A client component may import { createProject } from './actions' and pass it around, but what crosses the wire is a reference — an opaque, build-generated identifier — not the code. The body, its imports (your ORM, process.env, internal libraries), and every secret it touches stay server-side. This is the security property the directive guarantees: it is not "runs on the server if you're careful", it is enforced at build time; the function's source is stripped from the client bundle entirely.

Two ways to mark a function, with a real trade-off:

// 1) file level — every export in the file is an action
'use server';
export async function archiveOrder(id: string) { ... }
export async function restoreOrder(id: string) { ... }

// 2) inline — one action inside a server component file
async function submit() { 'use server'; /* ... */ }

File scope is right for a mutations module: it makes the boundary obvious (actions.ts contains only actions, so a reviewer knows every export is an endpoint). Inline scope is for small helpers colocated with the component that uses them. What is not allowed: exporting a non-async function from a 'use server' file (build error — actions must be async), or putting the directive in a client component's file (error: the directive belongs to server code; 'use client' and 'use server' are not a combination you get to choose).

Because actions are async, their failure modes are ordinary promise failure modes: a thrown error propagates to the nearest error.tsx (or to the caller when invoked from client code inside a transition, where you can catch it), a returned value must be serializable to cross the boundary, and non-serializable arguments (a DOM node, a function, a class instance) fail loudly rather than silently dropping fields.

// return shapes that work
return { ok: true, id };
return { ok: false, message: 'Out of stock' };
return formData;                 // FormData itself is fine
throw new Error('boom');         // -> error boundary / caught by caller

The last piece of the mental model: an action is an endpoint you did not write. Next exposes each one as a POST route reachable by anyone who can see your client code, so authentication, input validation, and rate limiting are your responsibility inside the body — the same obligations as a hand-written API route, with less ceremony around it. Where you do get something better than a naive API route is the plumbing: form wiring, serialization, and cache revalidation, which are the next two chapters.

Calling Actions from the Client: action={fn}, startTransition, and Serialization

An action is defined on the server and invoked from the client; the interesting part is how the call crosses the boundary and what the client is allowed to pass.

The most direct path is the form:

// app/projects/NewProjectButton.tsx  (client component)
'use client';
import { createProject } from '../actions';   // safe: reference only

export function NewProjectButton() {
  return (
    <form action={createProject}>
      <input name="name" placeholder="Project name" required />
      <button type="submit">Create</button>
    </form>
  );
}

React intercepts the submit, serializes the fields into FormData, posts to the current route with the action's identifier attached, runs your function on the server, and — crucially — re-renders the affected server components afterwards. No fetch, no manual router.refresh(), no endpoint URL to manage. The same reference works on a button: <button type="submit" formAction={duplicateProject}>Duplicate</button> lets one form offer two different mutations.

Outside a form, call it from an event handler inside a transition:

'use client';
import { useTransition } from 'react';
import { archiveOrder } from '../actions';

const [isPending, startTransition] = useTransition();

<button
  disabled={isPending}
  onClick={() => startTransition(async () => {
    const res = await archiveOrder(id);   // awaitable, returns your value
    if (!res.ok) toast.error(res.message);
  })}
>
  {isPending ? 'Archiving…' : 'Archive'}
</button>

The transition is doing two jobs. It marks the update as non-urgent, so React keeps the current UI interactive while the server work happens (instead of freezing on a spinner), and it gives you an isPending flag for the button. Note that an action invoked this way returns a promise you can await — which is what lets you branch on the result rather than guessing. From a plain click handler outside a transition, an action still works in most cases, but React logs a warning in development and you lose the pending semantics; the transition form is the correct habit.

What can cross in either direction is governed by the server-components serialization rules: primitives, plain objects and arrays, Date, Map/Set, class instances with enumerable data — but not functions, DOM nodes, or React elements with handlers. Your action's parameters and its return value both pass through that serializer, so design them like an API contract: an id plus a small payload in, a small result object out. Passing an entire FormData (the form path) sidesteps the question for inputs entirely.

There is one asymmetry to internalize: arguments flow in from the client (attacker-controlled, so validate), while the code never flows anywhere (defined at build time, so the reference cannot be swapped). That is why the security model is "validate what arrives", not "verify who is calling" — the caller's identity comes from cookies on the server, exactly as it would in a route handler.

Three usage patterns, ranked by how often you'll reach for them:

Pattern Use when
<form action={...}> the mutation is a submission; gets progressive enhancement for free
startTransition(() => action(...)) a button/menu action needing the result to branch on
<button formAction={...}> one form, several possible mutations

There is no fourth pattern where you fetch('/api/...') to call your own action — the reference is the fetch. The next chapter takes the form path seriously: what happens when the user's JavaScript never loads, and why that constraint produces better forms than the SPA default.

After the Write: Validation, Revalidation, and What an Action Owes the UI

Defining the action is the easy half. The half that shows up in review is everything the action must do between receiving a request and returning — because a server action is a public endpoint that happens to be called from your own UI.

The checklist, in the order the body should execute:

  1. Authenticate. Read the session (await cookies(), your auth helper) — never accept an identity from the arguments. A caller can invoke the action's endpoint directly with whatever arguments they like.
  2. Authorize. Check the specific resource: not "is logged in" but "may this user archive this order". Ownership checks live in the action, next to the mutation, where a reviewer can see them.
  3. Validate input. Parse the arguments with a schema before touching the database — see Validating Action Input for the full pattern. Unvalidated action input is the App Router equivalent of an unvalidated API route.
  4. Mutate once. Prefer a single transaction; an action can run more than once (double-click, network retry), so make the operation idempotent where it matters.
  5. Revalidate. Tell the router what changed: revalidatePath('/projects'), revalidateTag('orders'), or both.
  6. Return a serializable result the client can branch on: { ok, message, id } or { ok: false, errors: {...} }.
export async function archiveOrder(orderId: string) {
  const session = await requireUser();                 // 1. authn
  const order = await db.order.findUnique({ where: { id: orderId } });
  if (!order || order.userId !== session.id) return { ok: false, message: 'Not found' }; // 2+3

  await db.order.update({ where: { id: orderId }, data: { archived: true } });           // 4
  revalidateTag(`order:${orderId}`);                                                    // 5
  revalidatePath('/orders');
  return { ok: true };                                                                 // 6
}

Revalidation is what makes the UI correct without refetch code. After the mutation, Next re-renders the route's server components using fresh data and ships the updated payload to the client — the list you are looking at drops the archived row with no useEffect and no optimistic bookkeeping. Forgetting step 5 is the classic bug: the write succeeds, the page shows the old state, the user clicks again, and now you have a support ticket about duplicates.

Failures need their own design, because the action's caller is a client component with a pending UI. Two complementary strategies: return expected failures ({ ok: false } for validation and business rules — normal outcomes that deserve inline messages), and throw only for what should hit an error boundary (unexpected DB failure, upstream outage). A returned failure leaves the form filled in and focused; a thrown one replaces the region with the error UI and, in development, the stack. Pick deliberately — catching every throw and turning it into { ok: false } hides outages, while throwing on a routine "email already taken" makes the user's typed input disappear.

Finally, the two constraints interviews ask about. Why isn't this vulnerable to CSRF like an old PHP form? Actions are POSTs bound to build-generated identifiers shipped with your own bundle, so there is no ambient GET mutation to forge — but that is not a reason to skip authz, since anyone can call a valid identifier with crafted arguments. Why not an API route? You should still write route handlers when you need a public programmatic API (webhooks, third-party clients, non-React consumers); actions exist for mutations initiated by your own UI, where the framework can own the request/response plumbing, the revalidation, and the pending state.

Quiz

1. A client component imports a function from a 'use server' file and calls it. What actually crosses the network?

Question 1 options

2. Which statement about a 'use server' file is true?

Question 2 options

3. You call `await archiveOrder(id)` from a button's onClick and branch on the result. What is the correct invocation shape?

Question 3 options

4. An action succeeds but the list on screen still shows the deleted row. What was skipped?

Question 4 options

Flashcards

Question

What does the 'use server' directive guarantee?

Answer

File scope: every export is a server action — an async function whose body runs on the server regardless of where it is imported, shipped to the client only as an opaque build-generated reference. Implementation, ORM calls, and secrets never enter the client bundle; sync exports fail the build.

Question

Three ways to invoke a server action from the client?

Answer

<form action={fn}> (submit; works without JS), <button formAction={fn}> (alternate submit on a shared form), and inside startTransition(() => fn(args)) from an event handler — which returns an awaitable promise and yields isPending. All three send a reference plus serialized arguments.

Question

The required order of operations inside an action body?

Answer

Authenticate (session, never from args) -> authorize the specific resource -> validate input with a schema -> mutate once/idempotently -> revalidatePath or revalidateTag -> return a serializable { ok, ... } result. Expectable failures are returned; unexpected ones throw to error.tsx.

Question

Why does the UI update after an action with no refetch code?

Answer

revalidatePath/revalidateTag marks cached output stale, Next re-renders the route's server components with fresh data, and the updated RSC payload is streamed to the client. Skipping revalidation leaves the page on stale content — the user clicks again and you get duplicate submissions.

Question

When should you still write a route handler instead of a server action?

Answer

When something other than your own UI must call it: webhooks from a CMS/payment provider, public APIs for third-party clients, SSE/socket endpoints, or anything needing non-POST methods and custom headers. Actions are for mutations initiated by your React UI.

Revision Notes

Key Takeaways

  • 1. 'use server' guarantees the implementation never reaches the client — the wire format is a build-generated reference plus serialized arguments
  • 2. Actions are async endpoints: form action, formAction, or startTransition, all of which can await a serializable return value
  • 3. The body owes the system six things: authn, authz, validation, one idempotent mutation, revalidation, and a typed result
  • 4. Revalidation is what updates the UI — skipping it means stale lists and double submissions
  • 5. Return expected failures, throw unexpected ones; both shapes exist because the caller's form state differs

Interview Tips

  • • "What is a server action?" — an async function marked 'use server' that ships as a reference and executes server-side; mention the build-strips-the-body guarantee, not just "it's like an API"
  • • "Isn't calling my own function from the client insecure?" — anyone can POST to the identifier with crafted args, so authn/authz/validation live in the body; identifier binding only removes the ambient-GET CSRF class
  • • "Action vs API route?" — actions for UI-initiated mutations (framework owns plumbing, revalidation, pending state); route handlers for webhooks, third-party clients, non-POST and streaming endpoints
  • • "UI doesn't update after a mutation" — name revalidatePath/revalidateTag and the RSC re-render pipeline before suggesting router.refresh() or a client refetch

Cheat Sheet

Defining Server Actions

Declaration:

// app/projects/actions.ts
'use server';                                // file scope: all exports are actions
export async function createProject(input: FormData) {
  const session = await requireUser();         // 1 authn — never trust args
  const parsed = NewProject.parse(input);      // 2 authz + 3 validate
  const p = await db.project.create({ data: parsed });
  revalidatePath('/projects');                 // 5 refresh the route
  return { ok: true, id: p.id };               // 6 serializable result
}
// inline alternative: async function small() { 'use server'; ... } (must be async)

Invocation:

<form action={createProject}>...</form>              // FormData arg, no-JS capable
<button formAction={duplicate}>Duplicate</button>    // alternate action on one form
startTransition(async () => {                        // event handler + await result
  const res = await archiveOrder(id);
  if (!res.ok) toast.error(res.message);
});                                                  // isPending drives the button

Serialization: args and return values must cross the RSC serializer — primitives, plain objects/arrays, Date, FormData OK; functions/DOM nodes not.

Failure policy: expected/business errors -> return { ok: false, message } (keeps the form); unexpected -> throw -> error.tsx.

Security: action = public POST endpoint with your bundle's identifier → still authenticate, authorize per resource, validate, rate-limit. CSRF-free by identifier binding, not by magic.

Use route handlers instead for webhooks, public APIs, non-POST methods.

Continue Learning