A5 · Part 3 · Advanced
Server Actions
A function that runs on the server, called from the client, with no API route in between.
10On this page
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 handles | You still handle |
|---|---|
CSRF: the request's Origin must match the host, or serverActions.allowedOrigins | Authentication and authorisation in every action |
A 1 MB request body limit, raised with serverActions.bodySizeLimit | Validating every field, since FormData is untrusted |
| Encrypted action IDs, and unused actions removed from the bundle | Rate limiting anything expensive or abusable |
| Encrypting values captured by an inline action's closure | Shaping 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
| Situation | Better |
|---|---|
| A mobile app needs the same endpoint | A real API route — actions are framework-internal |
| Reading data on demand | A Server Component, or a query. Actions run one at a time per client, so they make a poor data-fetching layer. |
| Long-running work | A queue. An action holds a request open. |
| Streaming or websockets | Not what actions are for |
| Webhooks from a third party | A 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."
Opens in the editor: write it, run it, and check it against real tests.
All 54 for this level →Finished reading?
0 of 57 chapters read in this series