Server Actions in Next.js 16: Forms, Mutations, and the Pitfalls
Server Actions make mutations feel native in the App Router, until security and revalidation bite. Here is how I use them in production, and what to avoid.
On this page
The first time I shipped a Server Action to production, I deleted three files: an API route, a fetch wrapper, and a react-query mutation hook. The form just worked, including with JavaScript disabled. Then a junior on the team pointed a curl at the action's POST endpoint, passed userId: "someone-else", and updated a record that did not belong to him. That is the whole story of Server Actions in one paragraph: they collapse a mountain of plumbing, and they are public HTTP endpoints wearing a function's clothes.
I have been writing them daily since the App Router stabilized, and on Next.js 16 they are my default for any mutation. But the ergonomics hide a security model that punishes the careless. Let me show you how I actually use them, and the specific things that have bitten me.
What a Server Action really is
A Server Action is a function with the 'use server' directive that Next.js exposes as a callable endpoint. You import it like a function; the bundler replaces the client-side reference with a POST to an internal route, RPC-style. Under the hood every action is a POST to the page's path with an action ID in the headers. That detail matters: anyone who finds the ID can invoke it with any payload they like.
You can declare an action inline inside a Server Component, or in a dedicated file. I prefer a dedicated file with the directive at the top, because it forces me to think of these as an API surface, not as local helpers.
// app/posts/actions.ts
'use server';
import { z } from 'zod';
import { revalidateTag } from 'next/cache';
import { redirect } from 'next/navigation';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
const CreatePostSchema = z.object({
title: z.string().trim().min(3).max(120),
body: z.string().trim().min(1).max(10_000),
});
export type ActionState = {
ok: boolean;
error?: string;
fieldErrors?: Record<string, string[]>;
};That 'use server' at the top of the file marks every export as an action. There is no second layer of protection. If it is exported and the file is marked, it is reachable.
A form wired straight to an action
The headline feature is progressive enhancement: a <form action={fn}> works before React hydrates. The browser submits multipart form data to the action endpoint, Next runs it on the server, and the response drives navigation or revalidation. No client JavaScript required for the happy path.
Here is the full action, with the four things I never skip: authenticate, authorize, validate, revalidate.
// app/posts/actions.ts (continued)
export async function createPost(
_prev: ActionState,
formData: FormData,
): Promise<ActionState> {
// 1. Authenticate every call. Never trust the session being "implied".
const session = await auth();
if (!session?.user) {
return { ok: false, error: 'Not signed in.' };
}
// 2. Validate input. Coerce FormData into a typed object.
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
body: formData.get('body'),
});
if (!parsed.success) {
return {
ok: false,
error: 'Check the highlighted fields.',
fieldErrors: z.flattenError(parsed.error).fieldErrors,
};
}
// 3. Authorize. Derive ownership from the session, never from the payload.
if (!session.user.canPublish) {
return { ok: false, error: 'You do not have permission to publish.' };
}
const post = await db.post.create({
data: { ...parsed.data, authorId: session.user.id },
});
// 4. Revalidate the cache so readers see the new post.
revalidateTag('posts');
redirect(`/posts/${post.id}`);
}Three things in that code are load-bearing and easy to get wrong.
The authorId comes from session.user.id, not from formData. This is the rule that the curl attack broke. Never derive identity or ownership from arguments. Treat formData the way you treat req.body in any backend: hostile until validated.
z.flattenError is the Zod 4 API; if you are still on error.flatten(), that method is deprecated in v4 and gone in the next major. The fieldErrors shape maps cleanly to per-input error rendering.
And redirect() throws a special control-flow signal internally, so it must be the last thing you call. Anything after it is dead code. Do not wrap it in a try/catch that swallows the NEXT_REDIRECT error, or your redirect silently dies — a bug I have reviewed more than once.
The Server Component that renders the form needs no client code at all for the basic version:
// app/posts/new/page.tsx
import { createPost } from '../actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required minLength={3} />
<textarea name="body" required />
<button type="submit">Publish</button>
</form>
);
}This submits without hydration. But it gives the user no pending state and no inline errors. For that, you go client-side — without losing progressive enhancement.
Pending state and errors with useActionState
useActionState (the hook formerly called useFormState, renamed when it stabilized in React 19) wraps an action and gives you back the latest returned state plus a pending flag. It is the cleanest way to surface validation errors and disable the button during submission.
// app/posts/new/post-form.tsx
'use client';
import { useActionState } from 'react';
import { createPost, type ActionState } from '../actions';
const initial: ActionState = { ok: false };
export function PostForm() {
const [state, formAction, pending] = useActionState(createPost, initial);
return (
<form action={formAction}>
<input name="title" aria-invalid={!!state.fieldErrors?.title} />
{state.fieldErrors?.title && (
<p role="alert">{state.fieldErrors.title[0]}</p>
)}
<textarea name="body" aria-invalid={!!state.fieldErrors?.body} />
{state.fieldErrors?.body && (
<p role="alert">{state.fieldErrors.body[0]}</p>
)}
{state.error && <p role="alert">{state.error}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Publishing…' : 'Publish'}
</button>
</form>
);
}The signature is why the action takes (_prev, formData): useActionState injects the previous state as the first argument. The same action works both ways — bound directly to <form action> in a Server Component, or through useActionState in a Client Component — because the contract is identical. That dual-use property is the whole reason I return a typed ActionState object instead of throwing.
Critically, this form still degrades gracefully. If the JS bundle fails to load, the browser posts the form natively and Next runs the action server-side. You get the pending state and inline errors as an enhancement, not a dependency.
When you need an action that is not a form submission — a "favorite" toggle, an optimistic delete — call it inside useTransition so you keep a pending flag without blocking the UI:
'use client';
import { useTransition } from 'react';
import { toggleFavorite } from './actions';
export function FavoriteButton({ id, on }: { id: string; on: boolean }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => toggleFavorite(id))}
>
{on ? '★' : '☆'}
</button>
);
}revalidatePath vs revalidateTag
A mutation that does not revalidate is a bug with a delay. The user submits, the database changes, and the cached page keeps serving stale data until something else happens to bust it. I have shipped this bug. The fix is one line; the discipline is remembering it on every action.
There are two tools, and they are not interchangeable:
| Concern | revalidatePath('/posts') | revalidateTag('posts') |
|---|---|---|
| What it busts | One route segment and its data | Every fetch/cache entry tagged 'posts' |
| Coupling | Action must know the URL | Action only knows the tag |
| Best for | A specific page you control | Data shown across many routes |
| Risk | Misses other pages with the same data | Over-invalidation if tags are too broad |
I default to revalidateTag because it decouples the mutation from routing. The list page tags its fetch, the action busts the tag, and neither knows about the other:
// in a Server Component data fetch
const posts = await fetch('https://api.internal/posts', {
next: { tags: ['posts'] },
}).then((r) => r.json());On Next.js 16 with Cache Components, the same idea applies through cacheTag inside a 'use cache' boundary, with updateTag for read-your-writes consistency in the same request. The mental model is unchanged: tag what you read, invalidate what you wrote.
The pitfalls that actually cost me time
Actions are public endpoints. Bears repeating because every other mistake descends from forgetting it. The OWASP API Security Top 10 lists Broken Object Level Authorization as the number one risk, and a Server Action that trusts an ID in its arguments is a textbook BOLA. Run your auth and authorization checks at the top of every single action. There is no middleware that does it for you by default — middleware.ts runs on routing, and an action invocation may not pass through the matcher you expect.
Closures can capture and leak secrets. Inline actions can close over variables from the surrounding Server Component scope. Next encrypts bound arguments before sending action references to the client, but you should still never close over an API key or token and pass it into a bound action. Keep secrets read fresh inside the action body from process.env, not captured from an outer scope.
// Bad: secret captured in a closure, bound to a client reference.
export default function Page() {
const apiKey = process.env.PAYMENTS_KEY!; // captured
async function charge(formData: FormData) {
'use server';
await fetch('https://api.stripe.com/...', {
headers: { Authorization: `Bearer ${apiKey}` }, // leaky pattern
});
}
return <form action={charge} />;
}Read process.env.PAYMENTS_KEY inside the action body instead. It is server-only code; there is no reason to hoist the secret.
Large payloads get rejected. The body size limit for Server Actions defaults to 1 MB. File uploads blow past it instantly. Either raise it in next.config.ts deliberately, or — what I actually do — upload large files directly to object storage with a presigned URL and pass only the resulting key to the action.
// next.config.ts
import type { NextConfig } from 'next';
const config: NextConfig = {
experimental: {
serverActions: { bodySizeLimit: '2mb' },
},
};
export default config;Idempotency is your problem, not the framework's. Double-clicks, retries, and the browser's native resubmission on back-navigation all fire the same action twice. The pending flag from useActionState handles the double-click in the UI, but it does nothing for a network retry. For anything that creates records or moves money, enforce idempotency at the data layer: a unique constraint, an upsert keyed on a client-generated token, or a dedicated idempotency key column. Do not rely on the button being disabled.
Reading and writing cookies has rules. You can call cookies() inside an action to set a session or a flash message, and it works because actions run in a request context. But cookies().set() only takes effect if the action does not stream a response that has already committed headers — call it before redirect(), never after.
My checklist before an action ships
Every Server Action I merge clears this list. It takes thirty seconds and has caught real bugs in review:
- Authenticate at the top. No session, no work.
- Authorize against the session, never against arguments. Derive
userIdfromauth(). - Validate the entire input with Zod
safeParse, return typedfieldErrors. - Return a typed result object so the same action works in a form and in
useActionState. - Revalidate with
revalidateTag(orrevalidatePath) for every cache that shows the mutated data. - Idempotency enforced at the database for anything that creates or charges.
- No secrets captured in closures; read
process.envinside the body. - Redirect last, never inside a
try/catchthat swallows the control-flow throw.
Server Actions earned their place as my default mutation primitive because they delete real plumbing and keep progressive enhancement for free. But they move the security boundary into your function bodies, where it is easy to forget. Treat every action as the hostile public endpoint it actually is, and the ergonomics become pure upside.
Further reading
- Next.js documentation, "Server Actions and Mutations" — the canonical reference: nextjs.org/docs
- OWASP API Security Top 10, specifically API1:2023 Broken Object Level Authorization: owasp.org
- React documentation for
useActionStateanduseTransition: react.dev