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 zodValidate 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.