Zod Validation in Server Actions: Type-Safe Mutations
Zod is a TypeScript-first schema validation library that lets you define a schema once and use it for both TypeScript type inference and runtime validation. In Server Actions, Zod ensures that FormData or JSON payloads match your expected shape before you process them. This eliminates entire classes of bugs—type mismatches, missing fields, invalid formats—and gives you detailed error messages to return to the client.
Without Zod, you'd manually check each input: if (!email) throw new Error(...), if (!email.includes('@')) throw new Error(...). With Zod, you write one schema and reuse it for validation and type inference. Zod is particularly powerful in Server Actions because the schema is defined in TypeScript, shared between client and server, and executed at runtime on the server (where it's safe from client tampering).
Basic Zod Validation in a Server Action
Here's a simple example of validating a form submission:
// app/actions.ts
'use server';
import { z } from 'zod';
import { db } from '@/lib/db';
// Define schema once
const createUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
email: z.string().email('Invalid email address'),
age: z.number().int().min(18, 'Must be 18 or older').optional(),
});
type CreateUserInput = z.infer<typeof createUserSchema>;
export async function createUser(formData: FormData) {
// Parse FormData into an object
const data = {
name: formData.get('name'),
email: formData.get('email'),
age: formData.get('age') ? Number(formData.get('age')) : undefined,
};
// Validate with Zod
const result = createUserSchema.safeParse(data);
if (!result.success) {
// Return error details to the client
throw new Error(result.error.errors[0].message);
}
// result.data is typed as CreateUserInput
const user = await db.users.create(result.data);
return { success: true, userId: user.id };
}
safeParse() returns an object with success (boolean) and either data (if valid) or error (if invalid). If validation fails, error.errors is an array of validation errors, each with a message you can display to the user.
Extracting FormData Parsing Logic
Parsing FormData into a typed object is repetitive. Extract it into a helper:
// lib/form-utils.ts
export function parseFormData(formData: FormData, keys: string[]) {
const data: Record<string, any> = {};
for (const key of keys) {
const value = formData.get(key);
if (value instanceof File) {
data[key] = value;
} else if (value) {
data[key] = value instanceof FormData ? value : String(value);
}
}
return data;
}
// app/actions.ts
'use server';
import { z } from 'zod';
import { parseFormData } from '@/lib/form-utils';
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export async function createUser(formData: FormData) {
const data = parseFormData(formData, ['name', 'email']);
const result = createUserSchema.safeParse(data);
if (!result.success) {
throw new Error(result.error.errors[0].message);
}
return { success: true };
}
Handling Validation Errors with useActionState
To display validation errors without throwing exceptions, return them as part of the action's state:
// app/actions.ts
'use server';
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Invalid email'),
});
export async function createUser(prevState: any, formData: FormData) {
const data = {
name: formData.get('name'),
email: formData.get('email'),
};
const result = createUserSchema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Proceed with creation
await db.users.create(result.data);
return { success: true, errors: null };
}
The flatten() method converts the error structure to a field-keyed object: { name: ['error message'], email: ['error message'] }. Now you can display field-specific errors:
// app/components/CreateUserForm.tsx
'use client';
import { useActionState } from 'react';
import { createUser } from '@/app/actions';
export function CreateUserForm() {
const [state, formAction, isPending] = useActionState(createUser, {
success: false,
errors: null,
});
return (
<form action={formAction}>
<div>
<label>Name</label>
<input type="text" name="name" required disabled={isPending} />
{state.errors?.name && (
<p style={{ color: 'red' }}>{state.errors.name[0]}</p>
)}
</div>
<div>
<label>Email</label>
<input type="email" name="email" required disabled={isPending} />
{state.errors?.email && (
<p style={{ color: 'red' }}>{state.errors.email[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
</form>
);
}
Now each field shows its own error message, and the form doesn't throw—it just returns the state with errors, allowing the user to correct and retry.
Advanced Zod Features
Nested Objects: Validate nested data:
const postSchema = z.object({
title: z.string().min(1),
content: z.string().min(10),
author: z.object({
name: z.string().min(1),
email: z.string().email(),
}),
});
Discriminated Unions: Handle different data shapes:
const actionSchema = z.discriminatedUnion('type', [
z.object({ type: z.literal('like'), postId: z.string() }),
z.object({ type: z.literal('comment'), postId: z.string(), text: z.string() }),
]);
export async function handleAction(formData: FormData) {
const data = { type: formData.get('type'), /* ... */ };
const result = actionSchema.safeParse(data);
if (result.success) {
if (result.data.type === 'like') {
// result.data is typed as { type: 'like', postId: string }
await likePost(result.data.postId);
} else {
// result.data is typed as { type: 'comment', postId: string, text: string }
await addComment(result.data.postId, result.data.text);
}
}
}
Transforms: Modify data during validation:
const userSchema = z.object({
email: z.string().email().transform((e) => e.toLowerCase()),
tags: z.string().transform((t) => t.split(',').map((tag) => tag.trim())),
});
Custom Validation: Add custom logic:
const passwordSchema = z
.object({
password: z.string().min(8),
confirm: z.string().min(8),
})
.refine((data) => data.password === data.confirm, {
message: 'Passwords do not match',
path: ['confirm'], // Set which field the error applies to
});
Validation Comparison Table
| Method | Type Safety | Runtime Check | Client-Side | Server-Side | Learn Curve |
|---|---|---|---|---|---|
| Manual if-checks | No | Yes | No | Yes | Low |
| HTML5 validation | No | Yes | Yes | No | Low |
| Zod | Yes | Yes | Yes | Yes (native) | Medium |
| TypeScript interfaces | Yes | No | N/A | N/A | Low |
| JSON Schema | Partial | Yes | Yes (with libs) | Yes | Medium |
Zod shines because it provides both type safety (for TypeScript) and runtime validation (for untrusted input). Use Zod in Server Actions as your primary validation layer.
Key Takeaways
- Define a Zod schema once to validate and type-check your form data, API payloads, and database inputs.
- Use
safeParse()to validate without throwing errors; checkresult.successand return field errors to the client. - Extract FormData parsing into a helper function to reduce boilerplate in your Server Actions.
- Use
z.infer<typeof schema>to derive TypeScript types from your Zod schema, keeping validation and types in sync. - Zod's
refine()andtransform()methods enable custom validation logic and data transformation.
Frequently Asked Questions
Should I validate on the client and server?
Yes. Use HTML5 validation and optional client-side Zod (via zod-form-data) for instant feedback. Always validate on the server as your security layer. Client validation is for UX; server validation is for security.
What's the difference between parse() and safeParse()?
parse() throws an error if validation fails. safeParse() returns a result object with success and either data or error. Use safeParse() in Server Actions so you can return errors to the client without crashing.
How do I validate file uploads with Zod?
Zod has a z.instanceof(File) method. You can validate file size, type, and other properties using custom refinements: z.instanceof(File).refine((f) => f.size < 5e6, 'File must be under 5 MB').
Can I use Zod schemas across the network?
Yes, the schema definition lives in your TypeScript code (server or shared lib). The validation always happens on the server. You can optionally share the schema with client code for UI purposes (error messages, field hints), but the server is always the source of truth.
How do I handle optional fields in Zod?
Use .optional() or .nullable(). optional() means the field can be absent or undefined. nullable() means it can be null. You can also use .default(value) to provide a fallback.