Next.js Server Actions: Forms, Validation and Security
How Server Actions work, why they are safer than an API route for form handling, and the validation and authorisation you must not skip.
Table of contents
- The basics
- useActionState for results and pending state
- Validation is not optional
- Authorisation is not inherited
- What you get for free
- Rate limiting is still yours to add
- When to use an API route instead
- Frequently asked questions
- Can I call a Server Action outside a form?
- Are Server Actions cached?
- Can I return anything from an action?
- How do I show optimistic UI?
- Related reading
- References
A Server Action is a function that runs on the server and can be called from the client as if it were local. In practice it replaces most POST /api/* routes for form handling — with less code and better defaults.
The basics#
// actions/subscribe.ts
'use server';
export async function subscribe(formData: FormData) {
const email = formData.get('email');
await db.subscribers.create({ email });
}// A plain form. Works with JavaScript disabled.
<form action={subscribe}>
<input name="email" type="email" required />
<button type="submit">Subscribe</button>
</form>That last point is the underrated one: because this is a real form with a real action, the browser can submit it natively before React has hydrated. An onSubmit handler with fetch silently does nothing in that window.
useActionState for results and pending state#
'use client';
const [state, formAction, isPending] = useActionState(subscribe, {
status: 'idle',
message: '',
});
return (
<form action={formAction}>
<input name="email" type="email" required aria-invalid={state.status === 'error'} />
<button disabled={isPending}>{isPending ? 'Joining…' : 'Subscribe'}</button>
{state.status === 'error' && <p role="alert">{state.message}</p>}
</form>
);The action signature becomes (previousState, formData) and it returns the next state.
Validation is not optional#
A Server Action is a public HTTP endpoint. Next generates an id for it and anyone can invoke it with any payload. Client-side validation is a UX feature; server-side validation is the actual control.
'use server';
const schema = z.object({
email: z.email('Enter a valid email address.').max(254),
});
export async function subscribe(previous: State, formData: FormData) {
const parsed = schema.safeParse({ email: formData.get('email') });
if (!parsed.success) {
return { status: 'error', message: parsed.error.issues[0].message };
}
// parsed.data is now typed and validated
}Authorisation is not inherited#
This is the mistake that matters most. Hiding a button does not protect the action behind it.
'use server';
export async function deletePost(id: string) {
const session = await auth();
if (!session) throw new Error('Unauthorized');
const post = await db.posts.findById(id);
// Check ownership, not just authentication
if (post.authorId !== session.user.id && session.user.role !== 'admin') {
throw new Error('Forbidden');
}
await db.posts.delete(id);
revalidatePath('/blog');
}Every action needs its own check. There is no middleware between the client and the action that will do it for you.
What you get for free#
CSRF protection. Next compares the Origin header against the Host on every action invocation. An equivalent API route needs this written by hand.
Progressive enhancement, as above.
Automatic revalidation wiring — calling revalidatePath in an action also refreshes the client Router Cache.
Rate limiting is still yours to add#
Nothing stops a script hammering an action. At minimum, key a limiter on the client IP:
const limit = await rateLimit(clientIdentifier(await headers(), 'subscribe'), {
limit: 5,
windowMs: 60_000,
});
if (!limit.success) return { status: 'error', message: 'Too many attempts.' };When to use an API route instead#
Server Actions are for mutations triggered by your own UI. Use a Route Handler when you need a stable public URL, a webhook receiver, a non-form HTTP verb, a response that is not JSON, or a third-party integration. They coexist happily.
Frequently asked questions#
Can I call a Server Action outside a form?#
Yes — call it from an event handler or a useTransition. You lose progressive enhancement, so prefer a form when the interaction is a submission.
Are Server Actions cached?#
No. They always run on the server and are never cached, which is correct for mutations.
Can I return anything from an action?#
Anything serialisable. Not functions, class instances or Dates-with-methods — the same constraints as passing props across the client boundary.
How do I show optimistic UI?#
useOptimistic: render the expected result immediately, then reconcile when the action resolves.