The Zod Pattern Every Next.js App Needs
Runtime validation is the gap between TypeScript's compile-time guarantees and what actually arrives at your API. User input, external APIs, and database queries can all return unexpected shapes. Zod closes that gap.
Basic Schema Validation
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
age: z.number().int().min(18, 'Must be 18+').max(120).optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
})
// Infer TypeScript type from schema
type CreateUser = z.infer<typeof CreateUserSchema>
// Validate (throws on invalid)
const user = CreateUserSchema.parse(requestBody)
// Validate (returns result object, doesn't throw)
const result = CreateUserSchema.safeParse(requestBody)
if (!result.success) {
console.log(result.error.issues) // Detailed error messages
}
API Route Validation Pattern
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
plan: z.enum(['free', 'pro']).default('free'),
})
export async function POST(req: NextRequest) {
const body = await req.json()
const result = CreateUserSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{ error: 'Validation failed', issues: result.error.issues },
{ status: 400 }
)
}
const { name, email, plan } = result.data // Fully typed
// ... create user
}
Search Params and Query Validation
const SearchSchema = z.object({
q: z.string().min(1).max(200),
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['asc', 'desc']).default('desc'),
})
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const params = Object.fromEntries(searchParams.entries())
const result = SearchSchema.safeParse(params)
if (!result.success) {
return NextResponse.json({ error: 'Invalid query params' }, { status: 400 })
}
const { q, page, limit, sort } = result.data
// page and limit are numbers, not strings (coerce handles URL param strings)
}
Form Validation with React Hook Form
'use client'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
const SignupSchema = z.object({
email: z.string().email(),
password: z.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'One uppercase letter required')
.regex(/[0-9]/, 'One number required'),
confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
})
type SignupForm = z.infer<typeof SignupSchema>
export default function SignupForm() {
const { register, handleSubmit, formState: { errors } } = useForm<SignupForm>({
resolver: zodResolver(SignupSchema)
})
const onSubmit = async (data: SignupForm) => {
await fetch('/api/auth/signup', {
method: 'POST',
body: JSON.stringify(data)
})
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register('email')} placeholder="Email" />
{errors.email && <p>{errors.email.message}</p>}
<input {...register('password')} type="password" />
{errors.password && <p>{errors.password.message}</p>}
<input {...register('confirmPassword')} type="password" />
{errors.confirmPassword && <p>{errors.confirmPassword.message}</p>}
<button type="submit">Sign up</button>
</form>
)
}
Validating External API Responses
Never trust external API shapes:
const GitHubUserSchema = z.object({
id: z.number(),
login: z.string(),
name: z.string().nullable(),
email: z.string().email().nullable(),
public_repos: z.number(),
followers: z.number(),
})
async function fetchGitHubUser(username: string) {
const res = await fetch(`https://api.github.com/users/${username}`)
const data = await res.json()
const result = GitHubUserSchema.safeParse(data)
if (!result.success) {
throw new Error(`Unexpected GitHub API response shape: ${result.error.message}`)
}
return result.data // Typed as GitHubUser
}
Nested Objects and Arrays
const OrderSchema = z.object({
customerId: z.string().cuid(),
items: z.array(z.object({
productId: z.string().cuid(),
quantity: z.number().int().min(1),
price: z.number().positive(),
})).min(1, 'Order must have at least one item'),
shippingAddress: z.object({
line1: z.string(),
city: z.string(),
state: z.string().length(2),
zip: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
country: z.string().default('US'),
}),
couponCode: z.string().optional(),
})
type Order = z.infer<typeof OrderSchema>
Ship It with Validation Pre-Built
The AI SaaS Starter Kit includes Zod schemas for all API routes, React Hook Form integration for all auth forms, and reusable validation utilities.
$99 one-time at whoffagents.com
Top comments (0)