Skip to content

Zod

Validate user input in server actions and API routes with Zod. Parse form data, return typed errors, and display them in your components — all with standard React patterns.

Install

bun add zod

Validate Server Actions

Define a schema, parse the form data with safeParse, and return field-level errors if validation fails. The happy path gives you a fully typed object.

import { z } from 'zod';
import { redirect } from '@cmj/juice/runtime';

const CreateTaskSchema = z.object({
  title: z.string().min(1, 'Title is required').max(200),
  priority: z.enum(['low', 'medium', 'high']).default('medium'),
});

async function createTask(formData: FormData) {
  'use server';
  const raw = Object.fromEntries(formData);
  const result = CreateTaskSchema.safeParse(raw);

  if (!result.success) {
    return { errors: result.error.flatten().fieldErrors };
  }

  // result.data is fully typed: { title: string, priority: 'low' | 'medium' | 'high' }
  await db.tasks.create(result.data);
  redirect('/', 303);
}

Validate API Routes

The same pattern works for query parameters and JSON request bodies in API routes. Use z.coerce for query strings since URL parameters are always strings.

import { z } from 'zod';

const QuerySchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(20),
});

export function GET(req: Request) {
  const url = new URL(req.url);
  const result = QuerySchema.safeParse(
    Object.fromEntries(url.searchParams),
  );

  if (!result.success) {
    return Response.json(
      { errors: result.error.flatten() },
      { status: 400 },
    );
  }

  const { page, limit } = result.data;
  return Response.json({ page, limit, items: [] });
}

Display Validation Errors

Use useActionState in a client component to receive the error object returned by the server action and render field-level messages.

'use client';
import React, { useActionState } from 'react';

export function TaskForm({ action }: { action: (formData: FormData) => Promise<any> }) {
  const [state, formAction, pending] = useActionState(action, null);

  return (
    <form action={formAction}>
      <label>
        Title
        <input name="title" />
        {state?.errors?.title && (
          <p className="error">{state.errors.title}</p>
        )}
      </label>

      <label>
        Priority
        <select name="priority">
          <option value="low">Low</option>
          <option value="medium">Medium</option>
          <option value="high">High</option>
        </select>
      </label>

      <button disabled={pending}>
        {pending ? 'Creating...' : 'Create Task'}
      </button>
    </form>
  );
}

Why Not Built-In?

Juice does not ship a validation layer because schema.safeParse(data) is one line. A framework abstraction over one line adds complexity without value. Bring your own schema library — Zod, Valibot, ArkType, or a plain if statement. They all work the same way: parse the input, return errors or proceed.