Next.js Server Actions receive form data and can validate it with Zod before doing any side effects. This gives you one place for validation logic, type-safe parsed data, and structured error messages for each field.
Why Zod with Server Actions?
| Benefit | Description |
|---|---|
| Type safety | Validated data is fully typed |
| Single source of truth | One schema for client and server |
| Structured errors | Field-level error messages |
| Runtime validation | Catches invalid data at runtime |
| Composable schemas | Build complex validations from simple ones |
Basic setup
Define the schema
// lib/schemas/contact.ts
import { z } from 'zod';
export const contactSchema = z.object({
name: z.string().min(1, 'Name is required').max(100, 'Name is too long'),
email: z.string().email('Please enter a valid email'),
message: z.string().min(10, 'Message must be at least 10 characters'),
subscribe: z.coerce.boolean().default(false),
});
export type ContactFormData = z.infer<typeof contactSchema>;
Create the Server Action
// app/actions/contact.ts
'use server';
import { contactSchema } from '@/lib/schemas/contact';
type ActionResult =
| { success: true; message: string }
| { success: false; errors: Record<string, string[]> };
export async function submitContact(formData: FormData): Promise<ActionResult> {
const raw = Object.fromEntries(formData);
const result = contactSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
const data = result.data;
// Save to database, send email, etc.
await saveContact(data);
return {
success: true,
message: 'Thank you for your message!',
};
}
Create the form component
// app/contact/page.tsx
'use client';
import { useActionState } from 'react';
import { submitContact } from '@/app/actions/contact';
import { Button, Input, Textarea } from '@/components/ui';
const initialState = { success: false, errors: {} };
export default function ContactPage() {
const [state, formAction, pending] = useActionState(submitContact, initialState);
if (state.success) {
return <p className="text-green-500">{state.message}</p>;
}
return (
<form action={formAction} className="space-y-4">
<Input
name="name"
label="Name"
error={state.errors?.name?.[0]}
/>
<Input
name="email"
type="email"
label="Email"
error={state.errors?.email?.[0]}
/>
<Textarea
name="message"
label="Message"
rows={5}
error={state.errors?.message?.[0]}
/>
<label className="flex items-center gap-2">
<input type="checkbox" name="subscribe" />
Subscribe to newsletter
</label>
<Button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send Message'}
</Button>
</form>
);
}
Advanced patterns
Nested objects
const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
postalCode: z.string().regex(/^\d{5}$/),
});
const orderSchema = z.object({
items: z.array(z.object({
productId: z.string(),
quantity: z.number().min(1),
})).min(1, 'At least one item required'),
shippingAddress: addressSchema,
billingAddress: addressSchema.optional(),
});
Conditional validation
const paymentSchema = z.object({
paymentMethod: z.enum(['card', 'bank', 'paypal']),
cardNumber: z.string().optional(),
bankAccount: z.string().optional(),
}).refine(
(data) => {
if (data.paymentMethod === 'card') {
return data.cardNumber && data.cardNumber.length === 16;
}
return true;
},
{
message: 'Card number must be 16 digits',
path: ['cardNumber'],
}
);
Transform and coerce
const schema = z.object({
// Coerce string to number
age: z.coerce.number().min(18),
// Coerce checkbox to boolean
terms: z.coerce.boolean(),
// Transform to lowercase
username: z.string().transform((val) => val.toLowerCase()),
// Transform date string
birthday: z.string().transform((val) => new Date(val)),
});
Client-side validation
Add client validation for instant feedback:
'use client';
import { useState } from 'react';
import { contactSchema } from '@/lib/schemas/contact';
import { submitContact } from '@/app/actions/contact';
export default function ContactForm() {
const [clientErrors, setClientErrors] = useState<Record<string, string>>({});
const [serverResult, setServerResult] = useState(null);
async function handleSubmit(formData: FormData) {
// Clear previous errors
setClientErrors({});
// Client-side validation (optional, for UX)
const raw = Object.fromEntries(formData);
const clientResult = contactSchema.safeParse(raw);
if (!clientResult.success) {
const errors: Record<string, string> = {};
for (const [key, messages] of Object.entries(clientResult.error.flatten().fieldErrors)) {
errors[key] = messages?.[0] || 'Invalid';
}
setClientErrors(errors);
return;
}
// Server validation (always required)
const serverResult = await submitContact(formData);
setServerResult(serverResult);
}
return (
<form action={handleSubmit}>
{/* Form fields with clientErrors displayed */}
</form>
);
}
Error message patterns
Field-level errors
// Return from Server Action
return {
success: false,
errors: {
email: ['Email is required', 'Must be a valid email'],
password: ['Password must be at least 8 characters'],
},
};
Form-level errors
// For errors that span multiple fields
return {
success: false,
errors: {
_form: ['Passwords do not match'],
},
};
Display in form
{state.errors?._form && (
<div className="bg-red-50 p-4 rounded-lg">
{state.errors._form.map((error) => (
<p key={error} className="text-red-600">{error}</p>
))}
</div>
)}
Integration with React Hook Form
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { contactSchema, type ContactFormData } from '@/lib/schemas/contact';
import { submitContact } from '@/app/actions/contact';
export default function ContactForm() {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
});
async function onSubmit(data: ContactFormData) {
const formData = new FormData();
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value));
});
const result = await submitContact(formData);
if (!result.success && result.errors) {
Object.entries(result.errors).forEach(([field, messages]) => {
setError(field as keyof ContactFormData, {
message: messages?.[0],
});
});
}
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('name')} />
{errors.name && <span>{errors.name.message}</span>}
<input {...register('email')} type="email" />
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register('message')} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Sending...' : 'Send'}
</button>
</form>
);
}
Summary
Form handling with Zod and Server Actions:
- Define schemas in shared files for type inference
- Use safeParse in Server Actions for validation
- Return structured errors with field names
- Display errors next to corresponding fields
- Add client validation for better UX (optional)
- Always validate server-side since clients can be bypassed
The Next.js Server Actions docs and Zod documentation have complete API references.
