Production Server Action Patterns
Validate input, return typed results, handle errors, and treat every action as a public, untrusted endpoint.
A Server Action is not just a convenient function — at build time it becomes a real public HTTP endpoint. Anyone can craft a request to it. That single fact should reshape how you write them: validate everything, authorize inside every action, and never rely on the client to have behaved.
The reliable shape
- Authorize first — check the session before touching data. The form being rendered is not proof the caller is allowed.
- Validate with a schema — parse
FormDatathrough Zod (or similar). Never trust field types or presence. - Return a typed result — a discriminated union like
{ ok: true }or{ ok: false, errors }pairs perfectly withuseActionStateand keeps the client honest. - Revalidate or redirect — reflect the change, then send the user on.
Errors: expected vs unexpected
Return expected errors (validation, 'email taken') as data your UI renders. Only throw for genuinely exceptional failures — those hit the nearest error.tsx. Do not throw new Error('Invalid email') for a validation problem; that is a bad user experience and leaks stack traces.
Example
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
const Schema = z.object({
title: z.string().min(1).max(120),
body: z.string().min(1),
});
type Result =
| { ok: true; id: string }
| { ok: false; errors: Record<string, string[]> };
export async function createPost(
_prev: Result | null,
formData: FormData,
): Promise<Result> {
// 1. Authorize — never trust the caller
const session = await auth();
if (!session) return { ok: false, errors: { _: ['Unauthorized'] } };
// 2. Validate
const parsed = Schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
return { ok: false, errors: parsed.error.flatten().fieldErrors };
}
// 3. Mutate — derive ownerId from the session, NOT the form
const post = await db.post.create({
data: { ...parsed.data, ownerId: session.userId },
});
// 4. Reflect the change
revalidatePath('/posts');
return { ok: true, id: post.id };
}When to use it
- A production form validates all input with Zod inside the Server Action before touching the database, blocking malformed requests.
- A delete action checks that the session user owns the resource before executing, treating the action as an untrusted API endpoint.
- A Server Action returns a typed discriminated union so the Client Component can render either success details or field-level errors.
More examples
Zod validation with typed result
A discriminated union return type gives callers type-safe access to either the created ID or the validation errors.
'use server';
import { z } from 'zod';
const schema = z.object({ title: z.string().min(3).max(100) });
type ActionResult = { success: true; id: string } | { success: false; errors: z.ZodIssue[] };
export async function createPost(formData: FormData): Promise<ActionResult> {
const result = schema.safeParse(Object.fromEntries(formData));
if (!result.success) return { success: false, errors: result.error.issues };
const post = await db.post.create({ data: result.data });
return { success: true, id: post.id };
}Authorization check before mutation
Every Server Action must independently verify identity and ownership — middleware alone is not enough.
'use server';
import { getSession } from '@/lib/auth';
export async function deletePost(id: string) {
const session = await getSession();
if (!session) throw new Error('Unauthenticated');
const post = await db.post.findUnique({ where: { id } });
if (post?.authorId !== session.userId) throw new Error('Forbidden');
await db.post.delete({ where: { id } });
}Safe error surfacing to client
Catching and re-wrapping errors prevents leaking database or internal error messages to the browser.
'use server';
export async function submitOrder(formData: FormData) {
try {
const order = await createOrder(Object.fromEntries(formData));
return { ok: true, orderId: order.id };
} catch (err) {
// Never expose raw error messages to the client
console.error(err);
return { ok: false, message: 'Order failed. Please try again.' };
}
}
Discussion