Good form validation protects your data and guides users to success. Zod provides type-safe schema validation that works seamlessly with Server Actions, giving you runtime validation and TypeScript types from a single schema. With proper error handling, you can display field-specific errors, validate complex rules, and create forms that help users fix mistakes easily. Let's master form validation!
Why Zod for Validation?
❌ Manual Validation
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
const email = formData.get('email') as string;
// Manual validation
if (!title) {
return { error: 'Title required' };
}
if (title.length < 3) {
return { error: 'Title too short' };
}
if (!email.includes('@')) {
return { error: 'Invalid email' };
}
// Problems:
// ❌ No type safety
// ❌ Verbose and error-prone
// ❌ Hard to maintain
// ❌ No TypeScript inference
// ❌ Easy to miss validations
}✅ Zod Validation
import { z } from 'zod';
const schema = z.object({
title: z.string().min(3),
email: z.string().email(),
});
export async function createPost(formData: FormData) {
const result = schema.safeParse({
title: formData.get('title'),
email: formData.get('email'),
});
if (!result.success) {
return { errors: result.error.flatten() };
}
// result.data is typed!
const { title, email } = result.data;
// Benefits:
// ✅ Type-safe
// ✅ Concise
// ✅ Maintainable
// ✅ TypeScript types inferred
// ✅ Rich validation rules
}Key Benefits of Zod
- Type safety: Infer TypeScript types from schemas
- Runtime validation: Validate data at runtime
- Rich validation: Email, URL, min/max, regex, custom rules
- Error messages: Detailed, customizable error messages
- Composable: Reuse and combine schemas
- Zero dependencies: Lightweight and fast
Installing Zod
npm install zod
# Or with other package managers:
yarn add zod
pnpm add zodBasic Zod Validation
Creating a Schema
import { z } from 'zod';
export const createPostSchema = z.object({
title: z.string().min(3, 'Title must be at least 3 characters'),
slug: z.string().min(1, 'Slug is required'),
content: z.string().min(10, 'Content must be at least 10 characters'),
excerpt: z.string().optional(),
category: z.enum(['tech', 'design', 'business'], {
errorMap: () => ({ message: 'Invalid category' }),
}),
published: z.boolean().default(false),
});
// Infer TypeScript type from schema
export type CreatePostInput = z.infer<typeof createPostSchema>;
// ✅ Schema defines all validation rules
// ✅ Custom error messages
// ✅ TypeScript type inferred automaticallyUsing Schema in Server Action
'use server';
import { createPostSchema } from '@/app/schemas/post';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
// Parse FormData into object
const data = {
title: formData.get('title'),
slug: formData.get('slug'),
content: formData.get('content'),
excerpt: formData.get('excerpt'),
category: formData.get('category'),
published: formData.get('published') === 'on',
};
// Validate with Zod
const result = createPostSchema.safeParse(data);
if (!result.success) {
// Return validation errors
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Type-safe validated data
const validatedData = result.data;
// Save to database
await db.posts.create({
data: validatedData,
});
revalidatePath('/blog');
return { success: true };
}
// ✅ safeParse returns { success, data?, error? }
// ✅ flatten() gives field-specific errors
// ✅ result.data is type-safeDisplaying Errors in Form
'use client';
import { createPost } from '@/app/actions/posts';
import { useState } from 'react';
export default function CreatePostPage() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
async function handleSubmit(formData: FormData) {
const result = await createPost(formData);
if (!result.success) {
setErrors(result.errors || {});
} else {
// Success - clear errors
setErrors({});
alert('Post created!');
}
}
return (
<form action={handleSubmit} className="space-y-6">
{/* Title field */}
<div>
<label htmlFor="title" className="block font-semibold mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
className={`w-full px-4 py-2 border rounded-lg ${
errors.title ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.title && (
<p className="text-red-600 text-sm mt-1">{errors.title[0]}</p>
)}
</div>
{/* Content field */}
<div>
<label htmlFor="content" className="block font-semibold mb-2">
Content
</label>
<textarea
id="content"
name="content"
rows={10}
className={`w-full px-4 py-2 border rounded-lg ${
errors.content ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors.content && (
<p className="text-red-600 text-sm mt-1">{errors.content[0]}</p>
)}
</div>
{/* Category field */}
<div>
<label htmlFor="category" className="block font-semibold mb-2">
Category
</label>
<select
id="category"
name="category"
className={`w-full px-4 py-2 border rounded-lg ${
errors.category ? 'border-red-500' : 'border-gray-300'
}`}
>
<option value="">Select category</option>
<option value="tech">Technology</option>
<option value="design">Design</option>
<option value="business">Business</option>
</select>
{errors.category && (
<p className="text-red-600 text-sm mt-1">{errors.category[0]}</p>
)}
</div>
<button
type="submit"
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Create Post
</button>
</form>
);
}
// ✅ Field-specific error display
// ✅ Red border on error
// ✅ Error message below field
// ✅ Clear on successCommon Validation Rules
String Validations
import { z } from 'zod';
const schema = z.object({
// Required string
name: z.string().min(1, 'Name is required'),
// Min/max length
username: z.string().min(3).max(20),
// Email validation
email: z.string().email('Invalid email address'),
// URL validation
website: z.string().url('Invalid URL'),
// Regex validation
phone: z.string().regex(/^d{10}$/, 'Phone must be 10 digits'),
// Starts/ends with
slug: z.string().regex(/^[a-z0-9-]+$/, 'Slug can only contain lowercase, numbers, and hyphens'),
// Optional string
bio: z.string().optional(),
// String with default
role: z.string().default('user'),
});
// ✅ Rich string validation
// ✅ Custom error messages
// ✅ Optional and default valuesNumber Validations
const schema = z.object({
// Number with min/max
age: z.number().min(18, 'Must be 18 or older').max(120),
// Positive/negative
price: z.number().positive('Price must be positive'),
rating: z.number().min(-5).max(5),
// Integer only
quantity: z.number().int('Must be a whole number'),
// Parse string to number
port: z.coerce.number().int().positive(),
// Optional number
discount: z.number().optional(),
});
// When parsing FormData (strings):
const data = {
age: parseInt(formData.get('age') as string, 10),
price: parseFloat(formData.get('price') as string),
};
// Or use coerce to auto-convert:
const schema = z.object({
age: z.coerce.number().min(18),
price: z.coerce.number().positive(),
});
// ✅ Number validation
// ✅ Coercion from strings
// ✅ Min/max rangesDate Validations
const schema = z.object({
// Date validation
birthDate: z.date(),
// Date in the past
eventDate: z.date().min(new Date(), 'Date must be in the future'),
// Date range
appointmentDate: z.date()
.min(new Date())
.max(new Date('2025-12-31')),
// Parse string to date
scheduledAt: z.coerce.date(),
});
// When parsing FormData:
const data = {
eventDate: new Date(formData.get('eventDate') as string),
};
// Or use coerce:
const schema = z.object({
eventDate: z.coerce.date().min(new Date()),
});
// ✅ Date validation
// ✅ Min/max dates
// ✅ Coercion from stringsBoolean and Enum Validations
const schema = z.object({
// Boolean
published: z.boolean(),
// Enum (specific values)
status: z.enum(['draft', 'published', 'archived']),
// Native enum
role: z.nativeEnum(UserRole),
// Literal values
type: z.literal('admin'),
// Union of literals
priority: z.union([
z.literal('low'),
z.literal('medium'),
z.literal('high'),
]),
});
// For checkboxes in FormData:
const data = {
published: formData.get('published') === 'on',
};
// ✅ Boolean from checkbox
// ✅ Enum validation
// ✅ Specific allowed valuesArray Validations
const schema = z.object({
// Array of strings
tags: z.array(z.string()).min(1, 'At least one tag required'),
// Array with max length
categories: z.array(z.string()).max(5, 'Maximum 5 categories'),
// Array of objects
items: z.array(z.object({
name: z.string(),
quantity: z.number().positive(),
})),
// Optional array
hobbies: z.array(z.string()).optional(),
});
// For multiple checkboxes in FormData:
const data = {
tags: formData.getAll('tags') as string[],
};
// ✅ Array validation
// ✅ Min/max length
// ✅ Complex nested validationObject and Nested Validations
const schema = z.object({
// Nested object
address: z.object({
street: z.string(),
city: z.string(),
zipCode: z.string().regex(/^d{5}$/),
}),
// Optional nested object
billing: z.object({
cardNumber: z.string(),
cvv: z.string(),
}).optional(),
// Array of objects
contacts: z.array(z.object({
name: z.string(),
email: z.string().email(),
})),
});
// ✅ Nested validation
// ✅ Complex structures
// ✅ Reusable schemasCustom Validation Rules
Refinements (Custom Logic)
const schema = z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // Which field to show error on
});
// Multiple refinements:
const schema = z.object({
startDate: z.date(),
endDate: z.date(),
})
.refine((data) => data.endDate > data.startDate, {
message: 'End date must be after start date',
path: ['endDate'],
})
.refine((data) => {
const days = (data.endDate.getTime() - data.startDate.getTime()) / (1000 * 60 * 60 * 24);
return days <= 365;
}, {
message: 'Date range cannot exceed 1 year',
path: ['endDate'],
});
// ✅ Custom validation logic
// ✅ Cross-field validation
// ✅ Specify error pathTransform Data
const schema = z.object({
// Trim whitespace
email: z.string().email().transform(val => val.toLowerCase().trim()),
// Convert to number
age: z.string().transform(val => parseInt(val, 10)),
// Parse JSON
metadata: z.string().transform(val => JSON.parse(val)),
// Convert slug
slug: z.string().transform(val =>
val.toLowerCase().replace(/s+/g, '-')
),
});
// ✅ Transform after validation
// ✅ Normalize data
// ✅ Type-safe transformationsAsync Validation
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
username: z.string().min(3),
}).refine(
async (data) => {
// Check if email already exists
const existingUser = await db.users.findUnique({
where: { email: data.email },
});
return !existingUser;
},
{
message: 'Email already registered',
path: ['email'],
}
);
// Use parseAsync instead of parse:
export async function register(formData: FormData) {
const result = await schema.safeParseAsync({
email: formData.get('email'),
username: formData.get('username'),
});
if (!result.success) {
return { errors: result.error.flatten().fieldErrors };
}
// Continue...
}
// ✅ Async database checks
// ✅ API validations
// ✅ Use safeParseAsyncComplete Validation Example
Registration Form with Full Validation
import { z } from 'zod';
export const registerSchema = z.object({
name: z.string()
.min(2, 'Name must be at least 2 characters')
.max(50, 'Name must be less than 50 characters'),
email: z.string()
.email('Invalid email address')
.transform(val => val.toLowerCase().trim()),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number'),
confirmPassword: z.string(),
age: z.coerce.number()
.int('Age must be a whole number')
.min(18, 'You must be at least 18 years old')
.max(120, 'Invalid age'),
agreeToTerms: z.literal(true, {
errorMap: () => ({ message: 'You must agree to the terms' }),
}),
newsletter: z.boolean().optional(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
export type RegisterInput = z.infer<typeof registerSchema>;
// ✅ Complete validation schema
// ✅ Password requirements
// ✅ Cross-field validation
// ✅ Type inference'use server';
import { registerSchema } from '@/app/schemas/user';
import { hash } from 'bcrypt';
import { redirect } from 'next/navigation';
export async function register(formData: FormData) {
// Parse FormData
const data = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
confirmPassword: formData.get('confirmPassword'),
age: formData.get('age'),
agreeToTerms: formData.get('agreeToTerms') === 'on',
newsletter: formData.get('newsletter') === 'on',
};
// Validate
const result = registerSchema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
const validatedData = result.data;
// Check if user exists
const existingUser = await db.users.findUnique({
where: { email: validatedData.email },
});
if (existingUser) {
return {
success: false,
errors: {
email: ['Email already registered'],
},
};
}
// Hash password
const hashedPassword = await hash(validatedData.password, 10);
// Create user
await db.users.create({
data: {
name: validatedData.name,
email: validatedData.email,
password: hashedPassword,
age: validatedData.age,
newsletter: validatedData.newsletter || false,
},
});
// Redirect to login
redirect('/login');
}
// ✅ Zod validation
// ✅ Database checks
// ✅ Password hashing
// ✅ Redirect on successinterface FormFieldProps {
label: string;
name: string;
type?: string;
required?: boolean;
error?: string[];
className?: string;
}
export function FormField({
label,
name,
type = 'text',
required = false,
error,
className = '',
}: FormFieldProps) {
const hasError = error && error.length > 0;
return (
<div className={className}>
<label htmlFor={name} className="block font-semibold mb-2">
{label}
{required && <span className="text-red-600 ml-1">*</span>}
</label>
<input
type={type}
id={name}
name={name}
required={required}
aria-invalid={hasError}
aria-describedby={hasError ? `${name}-error` : undefined}
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 ${
hasError
? 'border-red-500 focus:ring-red-500'
: 'border-gray-300'
}`}
/>
{hasError && (
<p
id={`${name}-error`}
className="text-red-600 text-sm mt-1"
role="alert"
>
{error[0]}
</p>
)}
</div>
);
}
// ✅ Reusable form field
// ✅ Accessible error display
// ✅ Visual error states
// ✅ ARIA attributes'use client';
import { register } from '@/app/actions/auth';
import { FormField } from '@/app/components/FormField';
import { useState } from 'react';
export default function RegisterPage() {
const [errors, setErrors] = useState<Record<string, string[]>>({});
async function handleSubmit(formData: FormData) {
const result = await register(formData);
if (!result.success) {
setErrors(result.errors || {});
}
}
return (
<div className="max-w-md mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Create Account</h1>
<form action={handleSubmit} className="space-y-6">
<FormField
label="Full Name"
name="name"
required
error={errors.name}
/>
<FormField
label="Email"
name="email"
type="email"
required
error={errors.email}
/>
<FormField
label="Password"
name="password"
type="password"
required
error={errors.password}
/>
<FormField
label="Confirm Password"
name="confirmPassword"
type="password"
required
error={errors.confirmPassword}
/>
<FormField
label="Age"
name="age"
type="number"
required
error={errors.age}
/>
<div>
<label className="flex items-start gap-2">
<input
type="checkbox"
name="agreeToTerms"
required
className="mt-1"
/>
<span className="text-sm">
I agree to the{' '}
<a href="/terms" className="text-blue-600 hover:underline">
Terms of Service
</a>
</span>
</label>
{errors.agreeToTerms && (
<p className="text-red-600 text-sm mt-1">
{errors.agreeToTerms[0]}
</p>
)}
</div>
<div>
<label className="flex items-center gap-2">
<input type="checkbox" name="newsletter" />
<span className="text-sm">Subscribe to newsletter</span>
</label>
</div>
<button
type="submit"
className="w-full px-6 py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700"
>
Create Account
</button>
</form>
</div>
);
}
// ✅ Complete registration form
// ✅ Reusable FormField components
// ✅ All validation rules applied
// ✅ Accessible and user-friendlyValidation Project Structure
Organization of schemas and validated forms
Select a file or folder to see details
Form Validation Best Practices
1. Always Validate on Server
// ✅ GOOD: Server-side validation (required)
export async function createPost(formData: FormData) {
const result = schema.safeParse(data);
if (!result.success) {
return { errors: result.error.flatten() };
}
// Process validated data
}
// ❌ BAD: Client-only validation
// Never trust client-side validation alone
// Users can bypass it with dev tools2. Provide Clear Error Messages
// ✅ GOOD: Clear, actionable errors
z.string().min(8, 'Password must be at least 8 characters')
z.string().email('Please enter a valid email address')
z.number().positive('Price must be greater than 0')
// ❌ BAD: Generic errors
z.string().min(8) // "String must contain at least 8 character(s)"
z.string().email() // "Invalid email"
// Users need to know how to fix errors3. Use Field-Specific Errors
// ✅ GOOD: Field-specific errors
return {
errors: {
email: ['Email already registered'],
password: ['Password too weak'],
}
};
// Display next to each field
{errors.email && <p className="text-red-600">{errors.email[0]}</p>}
// ❌ BAD: Generic error
return { error: 'Form validation failed' };
// User doesn't know which fields are wrong4. Validate Early, Return Fast
// ✅ GOOD: Validate before expensive operations
export async function createPost(formData: FormData) {
// Validate first
const result = schema.safeParse(data);
if (!result.success) {
return { errors: result.error.flatten() };
}
// Then do expensive operations
await uploadImage();
await saveToDatabase();
}
// Don't process invalid data5. Reuse Schemas
// ✅ GOOD: Shared schemas
// schemas/user.ts
export const userEmailSchema = z.string().email();
export const userPasswordSchema = z.string().min(8);
export const loginSchema = z.object({
email: userEmailSchema,
password: userPasswordSchema,
});
export const registerSchema = z.object({
name: z.string().min(2),
email: userEmailSchema,
password: userPasswordSchema,
confirmPassword: userPasswordSchema,
});
// Consistent validation across formsKey Takeaways
- Zod for validation - type-safe schema validation
- safeParse() - returns result without throwing
- Server-side required - always validate on server
- Field-specific errors - flatten().fieldErrors
- Custom validation - refine() for complex rules
- Clear error messages - help users fix mistakes
- Reusable schemas - DRY validation logic
What's Next?
You've mastered form validation with Zod! Next, we'll explore useFormStatus and useFormState Hooks—managing form loading states, pending submissions, and building forms with instant feedback using React's form hooks. You'll create forms that feel responsive and provide great UX!
We'll cover useFormStatus for submit button states, useFormState for form-level state management, and combining both for complete form experiences.
🛡️ Security First
Never trust client-side validation. Always validate on the server. Client-side validation is only for UX—anyone can bypass it. Server-side validation protects your data and application.