TypeScript

Zod v4 + Next.js Server Actions: End-to-End Type Safety

By Technspire Team
April 7, 2026
12 views

Server Actions feel like typed function calls and carry client-submitted, unvalidated data across a network boundary. TypeScript's types vanish at the wire; whatever the client sends is what the handler receives. Zod is the pragmatic answer, and Zod v4 has matured into a tool that can express production schemas without the escape hatches that v3 often required. This walk-through covers the shape of the production pattern: wrappers, shared schemas, uploads, error shapes, form state, and the testing approach that makes the whole thing verifiable.

Why the Boundary Matters

A Server Action declares a TypeScript signature. When a client calls it, the framework serialises the arguments, sends them over the network, and hands the deserialised payload to the function. TypeScript types are erased; the runtime has no idea what shape the data should be. A malicious or buggy client can post anything. If the first line of a Server Action uses the argument without validating it, the application trusts unverified input, which is the root cause of most real-world production bugs at this boundary.

Zod adds a runtime schema that mirrors the TypeScript type. The payload is validated before the rest of the function runs; if the shape is wrong, the request is rejected with an actionable error. Done well, the same schema defines the TypeScript type, validates at the boundary, and drives client-side form state. Done poorly, schemas duplicate types and drift out of sync.

What Zod v4 Changed

  • Significant performance work. Parsing large or deeply nested schemas is measurably faster; the cost at the request boundary stops being a concern for most applications.
  • Sharper discriminated unions. v4 catches more at type-level, with better inference on narrowed branches.
  • Improved error formatting. The default error shape is closer to what form libraries expect, reducing the translation layer between Zod errors and user-facing form state.
  • Stricter defaults. A handful of lenient v3 behaviours were tightened. Most codebases compile clean after a methodical pass over the schemas that relied on coercion or structural implicit behaviour.

The Core Pattern: One Wrapper, Every Action

Stop repeating the auth-check, validate, authorise, call pattern in every action. Build one wrapper that every action uses. The wrapper takes a Zod schema and a handler that receives the validated input and the authenticated session. Every action becomes a two-line declaration.

// lib/actions.ts
import { z } from 'zod';
import { auth } from '@/lib/auth';

export type ActionResult<Output> =
  | { ok: true; data: Output }
  | { ok: false; kind: 'validation'; fieldErrors: Record<string, string[]> }
  | { ok: false; kind: 'unauthenticated' }
  | { ok: false; kind: 'forbidden'; reason?: string }
  | { ok: false; kind: 'not_found' }
  | { ok: false; kind: 'error'; message: string };

type AuthedHandler<S extends z.ZodTypeAny, Out> = (ctx: {
  session: NonNullable<Awaited<ReturnType<typeof auth>>>;
  input: z.infer<S>;
}) => Promise<Out>;

export function action<S extends z.ZodTypeAny, Out>(
  schema: S,
  handler: AuthedHandler<S, Out>,
) {
  return async function (raw: unknown): Promise<ActionResult<Out>> {
    const session = await auth();
    if (!session?.user) return { ok: false, kind: 'unauthenticated' };

    const parsed = schema.safeParse(raw);
    if (!parsed.success) {
      return {
        ok: false,
        kind: 'validation',
        fieldErrors: parsed.error.flatten().fieldErrors as Record<string, string[]>,
      };
    }

    try {
      const data = await handler({ session, input: parsed.data });
      return { ok: true, data };
    } catch (err: any) {
      if (err?.kind === 'forbidden') return { ok: false, kind: 'forbidden', reason: err.reason };
      if (err?.kind === 'not_found') return { ok: false, kind: 'not_found' };
      return { ok: false, kind: 'error', message: 'Internal error' };
    }
  };
}

Three design choices make this wrapper useful in practice. First, the return type is a discriminated union of result shapes, not an exception flow. Exceptions thrown across the Server Action boundary lose their type information; a discriminated return lets the client narrow on result.kind and render the appropriate UI. Second, the handler receives a typed context object rather than positional arguments; adding fields later (a request ID, a tenant ID) is a non-breaking change. Third, error shapes for expected failures (forbidden, not-found) are separate from unexpected errors; the client can render them distinctly.

Writing an Action

// app/customers/actions.ts
'use server';

import { z } from 'zod';
import { action } from '@/lib/actions';
import { prisma } from '@/lib/prisma';

export const CreateCustomerInput = z.object({
  name: z.string().min(1).max(200),
  email: z.string().email(),
  orgNumber: z.string().regex(/^\d{6}-\d{4}$/).optional(),
  tier: z.enum(['free', 'pro', 'enterprise']),
});

export type CreateCustomerInput = z.infer<typeof CreateCustomerInput>;

export const createCustomer = action(CreateCustomerInput, async ({ session, input }) => {
  return prisma.customer.create({
    data: { ...input, ownerId: session.user.id },
  });
});

Both the TypeScript type and the runtime schema come from one declaration. The exported type is available to the client for form state; the schema is available to the server for validation. There is no second source of truth to drift.

Sharing Schemas Between Client and Server

A Zod schema can run on the client, too. Client-side validation with the same schema catches most issues before the user submits, keeping the network boundary available for the truly adversarial cases. The rule: import the schema module from both contexts; keep server-only dependencies (database clients, secrets) out of the schema file.

// app/customers/customer-schema.ts — pure, safe to import from both sides
import { z } from 'zod';

export const CreateCustomerInput = z.object({
  name: z.string().min(1, 'Name is required').max(200),
  email: z.string().email('Invalid email'),
  orgNumber: z.string().regex(/^\d{6}-\d{4}$/, 'Expected NNNNNN-NNNN').optional(),
  tier: z.enum(['free', 'pro', 'enterprise']),
});

export type CreateCustomerInput = z.infer<typeof CreateCustomerInput>;

// app/customers/new/form.tsx — client component uses the same schema
'use client';
import { CreateCustomerInput } from '../customer-schema';
import { createCustomer } from '../actions';

export function NewCustomerForm() {
  // ...use CreateCustomerInput for zodResolver, useActionState for response
}

Form State With useActionState

React 19's useActionState integrates cleanly with the discriminated result pattern. The form's state hook receives the previous result and the submitted form data; the Server Action returns the next state. Errors surface per-field without extra plumbing.

'use client';
import { useActionState } from 'react';
import { createCustomer } from '../actions';
import type { ActionResult } from '@/lib/actions';
import type { Customer } from '@prisma/client';

export function NewCustomerForm() {
  const [state, submit, pending] = useActionState<
    ActionResult<Customer> | null,
    FormData
  >(async (_prev, form) => {
    return createCustomer({
      name:      form.get('name'),
      email:     form.get('email'),
      orgNumber: form.get('orgNumber') || undefined,
      tier:      form.get('tier'),
    });
  }, null);

  const fieldErr = state?.ok === false && state.kind === 'validation'
    ? state.fieldErrors
    : {};

  return (
    <form action={submit}>
      <label>Name
        <input name="name" />
        {fieldErr.name?.[0] && <p className="text-red-600">{fieldErr.name[0]}</p>}
      </label>
      {/* ... */}
      <button disabled={pending}>{pending ? 'Creating...' : 'Create'}</button>
    </form>
  );
}

File Uploads With Zod

File uploads complicate things because the payload is no longer a plain JSON object. Zod v4 handles File instances well when paired with a FormData-aware action signature. Validation includes size limits, MIME sniffing (do not trust the declared MIME type), and any domain-specific rules.

import { z } from 'zod';

const UploadInput = z.object({
  title: z.string().min(1).max(200),
  file: z.instanceof(File)
    .refine((f) => f.size <= 10 * 1024 * 1024, 'Max 10MB')
    .refine((f) => ['application/pdf', 'image/png', 'image/jpeg'].includes(f.type), 'Unsupported type'),
});

export const uploadDocument = action(UploadInput, async ({ session, input }) => {
  const head = new Uint8Array(await input.file.slice(0, 12).arrayBuffer());
  if (!isValidMagicBytes(head, input.file.type)) {
    throw { kind: 'forbidden', reason: 'Declared MIME does not match file contents' };
  }

  const blob = await uploadToAzureBlob(input.file, session.user.tenantId);
  return prisma.document.create({
    data: { title: input.title, blobUrl: blob.url, ownerId: session.user.id },
  });
});

Discriminated Error Shapes Scale Better Than Flat Ones

The temptation with an error type is to flatten everything into a single { error: string } field. A discriminated union with distinct kind values scales better because the client can render different UI for different failures. A validation error shows per-field messages; a forbidden error prompts the user to request access; a not-found error redirects. Each is a distinct affordance, and they do not share a UI.

Transforms: Parse, Do Not Validate

Zod supports transform, which runs after validation and can shape the output into the domain model the handler wants. Use this to strip whitespace, coerce formats, or replace a string with a resolved entity. The result is that the handler receives data in its final, typed form, never the raw client shape.

const OrgNumberInput = z
  .string()
  .regex(/^\d{6}-?\d{4}$/)
  .transform((s) => s.replace('-', ''));   // canonical form without the dash

// z.infer<typeof OrgNumberInput> is string; handler never sees the dashed variant
const schema = z.object({ orgNumber: OrgNumberInput });

Testing the Boundary

The validation boundary is worth its own tests. Call the action with bad input shapes and assert on the returned validation error structure. These tests run in milliseconds and catch regressions that integration tests would miss.

describe('createCustomer validation', () => {
  it('rejects missing name', async () => {
    const res = await createCustomer({ email: 'a@b.com', tier: 'free' });
    expect(res.ok).toBe(false);
    if (!res.ok && res.kind === 'validation') {
      expect(res.fieldErrors.name).toBeDefined();
    }
  });

  it('rejects invalid org number format', async () => {
    const res = await createCustomer({
      name: 'Acme', email: 'a@b.com', tier: 'pro', orgNumber: '1234',
    });
    expect(res.ok).toBe(false);
    if (!res.ok && res.kind === 'validation') {
      expect(res.fieldErrors.orgNumber).toBeDefined();
    }
  });
});

Migrating From Zod v3

Zod v4 is largely backwards compatible. The rough path: install v4 in a branch, let TypeScript surface the differences, address them one schema at a time. The common work is around schemas that used implicit coercion, relied on v3's looser union inference, or constructed errors in ways the new format does not support. For most application codebases the migration is a two-day effort, not a sprint.

What This Pattern Buys You

  • One declaration per action produces the TypeScript type, the runtime validator, and the client-side form contract.
  • Every action is auth-checked and input-validated before business logic runs, without per-action boilerplate.
  • Errors are shaped so the client can render per-field feedback, auth challenges, and retry prompts distinctly.
  • Testing the validation boundary is a unit test, not an integration one.
  • The framework-level complexity stays in one wrapper file; the rest of the codebase writes domain logic.

The larger takeaway about Server Actions: they feel like function calls and are therefore easy to treat as trusted. They are not. The boundary is the same HTTP boundary as any other endpoint. Zod v4 is the tool that keeps the ergonomics of function calls while enforcing the discipline the boundary requires.

Ready to Transform Your Business?

Let's discuss how we can help you implement these solutions and achieve your goals with AI, cloud, and modern development practices.

No commitment required • Expert guidance • Tailored solutions