open atlas
↑ Back to track
Next.js, zero to senior NEXT · 02 · 03

Server actions: RPC over POST that you must treat as public endpoints

A server action is RPC over POST: 'use server' gives the function an id clients invoke. Forms work pre-hydration; args must be React-serializable. Every action is a public endpoint — validate and authorize inside, revalidate after mutation; external callers get route handlers.

NEXT Senior ◷ 18 min
Level
FoundationsJuniorMiddleSenior

A pentest report lands on a B2B team’s desk with a critical finding: any logged-in user can delete any other user’s account. The team is confused — the delete button only renders for admins, they checked. The pentester’s reproduction doesn’t use the button. They opened the network tab as an admin once, saw the POST a server action makes — same URL as the page, a Next-Action header carrying an opaque id — then replayed that exact request from a regular user’s session. The action ran. deleteUser had an authorization check in the component (“render the button only if admin”) and none in the function. But a server action isn’t a function call protected by your UI — it’s an HTTP endpoint the compiler generated from your function, reachable by anyone who can send a POST with the right id. The UI gate was theater; the endpoint was public. That mental shift — every 'use server' function is an unauthenticated API route until you write the auth inside it — is the difference between server actions as a productivity win and server actions as your next incident.

Mechanism: what ‘use server’ actually compiles to

The 'use server' directive — at the top of an async function or a whole file — tells the bundler to split that function out of the client graph and replace every client-side reference to it with a stable id. Invoking the action from the browser sends a POST to the current page’s URL with a Next-Action header carrying that id and the arguments in the body; the server routes the request to the compiled function, runs it, and streams back the return value together with any updated RSC payload. It is RPC with the plumbing hidden: no route file, no fetch call, no endpoint naming — but the wire is still there, and everything that crosses it obeys wire rules.

Arguments and return values must be serializable by React’s protocol — a superset of JSON that handles Date, Map, Set, FormData, typed arrays, and promises, but not class instances, functions, or anything with methods. Return a Prisma model wrapped in a Decimal-bearing class and you get a runtime serialization error; the habit is returning plain DTOs ({ id, name } shapes you construct deliberately). Closures work — an action defined inside a server component can capture variables from its scope — and Next encrypts those captured values with a build-specific key before sending them through the client. Encryption is not authorization, though: never treat a closed-over value as a secret the user may not see, and never trust it as tamper-proof input either.

One more wire rule that surprises teams: a given client runs actions sequentially — invocations queue, and a slow action blocks the ones behind it. Actions are mutation primitives, not a general parallel data-fetching mechanism.

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

import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { getSession } from '~/lib/auth';

const Input = z.object({ projectId: z.string().uuid(), name: z.string().min(1).max(120) });

export async function renameProject(raw: unknown) {
  // 1. Authenticate + authorize INSIDE the action — the only gate that exists.
  const session = await getSession();
  if (!session) throw new Error('Unauthenticated');

  // 2. Validate: the wire is untyped; TypeScript signatures are not validation.
  const input = Input.parse(raw);
  const project = await db.project.findUnique({ where: { id: input.projectId } });
  if (project?.ownerId !== session.userId) throw new Error('Forbidden');

  // 3. Mutate, then tell the caches.
  await db.project.update({ where: { id: input.projectId }, data: { name: input.name } });
  revalidateTag(`project-${input.projectId}`);
  return { ok: true as const };
}

Progressive enhancement: forms that work before JavaScript

Pass an action to a form — <form action={renameProject}> — and the form works before hydration: with JS disabled, slow, or not yet loaded, the browser performs a plain form POST, Next routes it to the action, and the page re-renders with the result. On a hydrated page the same form upgrades to the RPC path with no full-page reload. useActionState layers state on top (previous result, pending flag, and an enhanced action), and useFormStatus gives a child component the pending state for disabling the submit button.

This is a real, measurable win on slow networks: the user on a 3G connection whose 200 KB of JS hasn’t arrived can still submit the checkout form. But the guarantee is narrow — it covers form submissions only. An action wired to onClick, fired from a useEffect, or called imperatively needs hydrated JavaScript like any other handler. Teams that sell themselves “our app works without JS because we use server actions” while triggering every mutation from buttons with click handlers have progressive enhancement on the brochure, not in the product.

Tradeoff: the form path constrains your inputs to FormData — strings, basically — which means parsing and coercing on the server (zod with z.coerce earns its keep). The RPC path takes rich serializable arguments but gives up the no-JS guarantee. Pick per mutation; checkout and login lean toward forms, a drag-to-reorder obviously doesn’t.

Quiz

A deleteUser server action is only ever rendered into a form on the admin dashboard, behind an isAdmin check in the component. A regular authenticated user crafts a POST with the action's id. What happens, and what's the correct model?

Security: every action is a public endpoint

The pentest story generalizes into a checklist. Authenticate and authorize inside every action — the action is reachable regardless of which components rendered, so the function body is the only gate that exists. Validate every argument — the TypeScript signature is a compile-time fiction; at runtime the body of the POST is attacker-controlled bytes, so parse with a schema before touching the database, exactly as you would in a route handler. When you write an action, ask yourself: if a logged-in user replayed this POST with an arbitrary projectId, what happens? That question surfaces the authorization gap before the pentester does. Treat exported actions as your API surface: Next.js does eliminate ids for actions that are never referenced, and ids are non-deterministic per build — but unguessable-id is obscurity, not access control, because anyone the UI legitimately serves sees the id in their own network tab.

The framework does carry part of the burden: server actions only accept POST, and Next compares the Origin header to the Host header, rejecting mismatches — solid same-site CSRF protection out of the box (with allowedOrigins config for proxied setups where the two legitimately differ). Know precisely what that buys: it stops cross-site forgery; it does nothing about your own authenticated users calling actions they found, which is an authorization problem only your code solves.

Failure mode: the subtler version of the pentest bug is the action that authorizes the entity type but not the instance — checks “is logged in” and then updates whatever projectId arrives. Insecure direct object reference, mass-assignable through a typed-looking function signature. The ownership check in the code sample (project.ownerId !== session.userId) is the line that gets skipped in code review because the function “is only called from the owner’s page”.

Revalidate after mutation — and when to prefer route handlers

A mutation that succeeds but leaves stale caches looks identical to a failed mutation in a bug report (“I saved and nothing changed”). End every state-changing action by declaring what it invalidated: revalidatePath or revalidateTag purges the server caches and — because it ran inside an action — invalidates the mutating user’s client Router Cache, so the UI they’re looking at refreshes in the same round trip. redirect() composes with it for the create-then-view flow. Forgetting this line is the most common server-action bug in production, and it’s invisible in development where caching is mostly off.

Server actions are for your own app’s mutations from your own React tree. Prefer a route handler when the caller isn’t your UI: webhooks from Stripe or a CMS, mobile clients, third-party integrations — they can’t address an opaque, per-build action id and need a stable URL with explicit methods. Route handlers also win for GET endpoints (actions are POST-only mutations by design), for responses that aren’t React-serializable (files, custom headers, redirects with specific status codes), and for high-concurrency client-side firing, since one client’s actions execute sequentially while route handler requests run in parallel. A reasonable team rule: actions for in-app form and button mutations, route handlers for everything with an external caller or an OpenAPI row.

Quiz

Your CMS needs to notify the site when an article changes, and your mobile app (native, no React) needs the same publish-article capability the web's server action has. Where do these two callers belong?

Recall before you leave
  1. 01
    Describe the wire mechanics of a server action: how it's invoked, what can cross, and the execution constraint.
  2. 02
    Why must every action validate and authorize internally, what does the framework's CSRF check cover, and when do you choose a route handler instead?
Recap

A server action is remote procedure call with hidden plumbing: ‘use server’ splits the function out of the client bundle, gives it a stable per-build id, and invocation becomes a POST to the page URL with a Next-Action header; the server runs the function and streams back the result with updated RSC payload. Wire rules apply — arguments and returns must be React-serializable (Date, Map, Set, FormData yes; class instances and functions no, so return deliberate plain DTOs), closures are encrypted with a build key but that’s neither secrecy nor integrity you should lean on, and one client executes actions sequentially, which makes them mutation primitives rather than a parallel fetch mechanism. Forms passed an action work before hydration — a plain POST that upgrades to RPC once JS arrives, with useActionState and useFormStatus for pending and result state — but the no-JS guarantee covers form submissions only, and the FormData path means server-side parsing and coercion. The security model is the load-bearing lesson: every action is a public endpoint reachable by anyone who can send the id, regardless of which components rendered, so authentication, per-instance authorization (ownership checks, not just is-logged-in — the IDOR miss), and schema validation must live inside the function; the framework’s Origin/Host comparison blocks cross-site forgery and nothing more. End every mutation by revalidating — revalidatePath/revalidateTag purge the server caches and, from inside an action, refresh the mutating user’s Router Cache; the forgotten revalidate is the top production bug because dev mode hides it. And know the boundary of the tool: route handlers, not actions, serve external callers — webhooks, native mobile apps, anything needing a stable URL, GET semantics, non-serializable responses, or parallelism — because an opaque per-build action id is not an API contract. Now when you write an action, you’ll check three things before shipping it: is there an ownership check inside, is every argument parsed against a schema, and does every mutation end with a revalidate call.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.