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

  1. Authorize first — check the session before touching data. The form being rendered is not proof the caller is allowed.
  2. Validate with a schema — parse FormData through Zod (or similar). Never trust field types or presence.
  3. Return a typed result — a discriminated union like { ok: true } or { ok: false, errors } pairs perfectly with useActionState and keeps the client honest.
  4. 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

Example · typescript
'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.

Example · ts
'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.

Example · ts
'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.

Example · ts
'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

  • Be the first to comment on this lesson.