Validation Pattern

Validate user input with Zod for type-safe, reusable validation schemas.

Overview#

Input validation is critical for security and data integrity. Zod provides TypeScript-first schema validation that works on both client and server, with excellent error messages and type inference.

When to use:

  • Form validation
  • API input validation
  • Environment variable validation
  • Configuration validation
  • Data transformation

Key features:

  • TypeScript type inference
  • Composable schemas
  • Custom error messages
  • Async validation
  • Transform and refine

Code Example#

Basic Schemas#

1// lib/validations/common.ts 2import { z } from 'zod' 3 4// Email validation 5export const emailSchema = z 6 .string() 7 .email('Invalid email address') 8 .toLowerCase() 9 .trim() 10 11// Password validation 12export const passwordSchema = z 13 .string() 14 .min(8, 'Password must be at least 8 characters') 15 .regex(/[A-Z]/, 'Password must contain an uppercase letter') 16 .regex(/[a-z]/, 'Password must contain a lowercase letter') 17 .regex(/[0-9]/, 'Password must contain a number') 18 19// Username validation 20export const usernameSchema = z 21 .string() 22 .min(3, 'Username must be at least 3 characters') 23 .max(20, 'Username must be at most 20 characters') 24 .regex( 25 /^[a-zA-Z0-9_]+$/, 26 'Username can only contain letters, numbers, and underscores' 27 ) 28 29// URL validation 30export const urlSchema = z 31 .string() 32 .url('Invalid URL') 33 .or(z.literal('')) 34 35// Phone number 36export const phoneSchema = z 37 .string() 38 .regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number') 39 40// Slug validation 41export const slugSchema = z 42 .string() 43 .min(1) 44 .max(100) 45 .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Invalid slug format') 46 47// UUID validation 48export const uuidSchema = z.string().uuid('Invalid ID format') 49 50// Non-empty string 51export const nonEmptyStringSchema = z 52 .string() 53 .min(1, 'This field is required') 54 .trim() 55 56// Positive number 57export const positiveNumberSchema = z 58 .number() 59 .positive('Must be a positive number')

Form Schemas#

1// lib/validations/auth.ts 2import { z } from 'zod' 3import { emailSchema, passwordSchema } from './common' 4 5export const loginSchema = z.object({ 6 email: emailSchema, 7 password: z.string().min(1, 'Password is required'), 8 rememberMe: z.boolean().optional() 9}) 10 11export const registerSchema = z 12 .object({ 13 name: z.string().min(2, 'Name must be at least 2 characters'), 14 email: emailSchema, 15 password: passwordSchema, 16 confirmPassword: z.string() 17 }) 18 .refine(data => data.password === data.confirmPassword, { 19 message: 'Passwords do not match', 20 path: ['confirmPassword'] 21 }) 22 23export const forgotPasswordSchema = z.object({ 24 email: emailSchema 25}) 26 27export const resetPasswordSchema = z 28 .object({ 29 token: z.string().min(1), 30 password: passwordSchema, 31 confirmPassword: z.string() 32 }) 33 .refine(data => data.password === data.confirmPassword, { 34 message: 'Passwords do not match', 35 path: ['confirmPassword'] 36 }) 37 38// Export types 39export type LoginInput = z.infer<typeof loginSchema> 40export type RegisterInput = z.infer<typeof registerSchema>

API Input Validation#

1// lib/validations/api.ts 2import { z } from 'zod' 3 4// Pagination params 5export const paginationSchema = z.object({ 6 page: z.coerce.number().int().positive().default(1), 7 limit: z.coerce.number().int().min(1).max(100).default(20), 8 sort: z.string().optional(), 9 order: z.enum(['asc', 'desc']).default('desc') 10}) 11 12// Search params 13export const searchSchema = z.object({ 14 q: z.string().min(1).max(100).optional(), 15 filters: z.record(z.string()).optional() 16}) 17 18// ID param 19export const idParamSchema = z.object({ 20 id: z.string().cuid() 21}) 22 23// Create post 24export const createPostSchema = z.object({ 25 title: z.string().min(1).max(200), 26 content: z.string().min(1), 27 excerpt: z.string().max(500).optional(), 28 slug: z.string().regex(/^[a-z0-9-]+$/).optional(), 29 published: z.boolean().default(false), 30 categoryId: z.string().cuid().optional(), 31 tags: z.array(z.string()).max(10).optional() 32}) 33 34// Partial update 35export const updatePostSchema = createPostSchema.partial() 36 37export type CreatePostInput = z.infer<typeof createPostSchema> 38export type UpdatePostInput = z.infer<typeof updatePostSchema>

Validation Helper#

1// lib/validations/validate.ts 2import { z } from 'zod' 3import { NextRequest, NextResponse } from 'next/server' 4 5export class ValidationError extends Error { 6 constructor( 7 public errors: z.ZodError, 8 message = 'Validation failed' 9 ) { 10 super(message) 11 this.name = 'ValidationError' 12 } 13} 14 15export function validate<T extends z.ZodSchema>( 16 schema: T, 17 data: unknown 18): z.infer<T> { 19 const result = schema.safeParse(data) 20 21 if (!result.success) { 22 throw new ValidationError(result.error) 23 } 24 25 return result.data 26} 27 28// API route wrapper 29type Handler<T> = ( 30 req: NextRequest, 31 validated: T, 32 context?: any 33) => Promise<NextResponse> 34 35export function withValidation<T extends z.ZodSchema>( 36 schema: T, 37 handler: Handler<z.infer<T>> 38) { 39 return async (req: NextRequest, context?: any) => { 40 try { 41 const body = await req.json() 42 const validated = validate(schema, body) 43 return handler(req, validated, context) 44 } catch (error) { 45 if (error instanceof ValidationError) { 46 return NextResponse.json( 47 { 48 error: 'Validation failed', 49 details: error.errors.flatten().fieldErrors 50 }, 51 { status: 400 } 52 ) 53 } 54 throw error 55 } 56 } 57} 58 59// Usage 60export const POST = withValidation(createPostSchema, async (req, data) => { 61 const post = await prisma.post.create({ data }) 62 return NextResponse.json(post, { status: 201 }) 63})

Custom Validators#

1// lib/validations/custom.ts 2import { z } from 'zod' 3import { prisma } from '@/lib/db' 4 5// Unique email check 6export const uniqueEmailSchema = z 7 .string() 8 .email() 9 .refine( 10 async email => { 11 const existing = await prisma.user.findUnique({ 12 where: { email }, 13 select: { id: true } 14 }) 15 return !existing 16 }, 17 { message: 'Email already in use' } 18 ) 19 20// Check resource exists 21export function existsInDatabase( 22 table: 'user' | 'post' | 'team', 23 field: 'id' | 'slug' = 'id' 24) { 25 return z.string().refine( 26 async value => { 27 const record = await (prisma as any)[table].findUnique({ 28 where: { [field]: value }, 29 select: { id: true } 30 }) 31 return !!record 32 }, 33 { message: `${table} not found` } 34 ) 35} 36 37// File validation 38export const fileSchema = z.object({ 39 name: z.string(), 40 size: z.number().max(5 * 1024 * 1024, 'File must be less than 5MB'), 41 type: z.string().refine( 42 type => ['image/jpeg', 'image/png', 'image/webp'].includes(type), 43 'Invalid file type' 44 ) 45}) 46 47// Credit card (basic Luhn check) 48export const creditCardSchema = z 49 .string() 50 .regex(/^\d{13,19}$/, 'Invalid card number') 51 .refine(luhnCheck, 'Invalid card number') 52 53function luhnCheck(cardNumber: string): boolean { 54 let sum = 0 55 let isEven = false 56 57 for (let i = cardNumber.length - 1; i >= 0; i--) { 58 let digit = parseInt(cardNumber[i], 10) 59 60 if (isEven) { 61 digit *= 2 62 if (digit > 9) digit -= 9 63 } 64 65 sum += digit 66 isEven = !isEven 67 } 68 69 return sum % 10 === 0 70}

Form Validation Hook#

1// hooks/useFormValidation.ts 2'use client' 3 4import { useState, useCallback } from 'react' 5import { z } from 'zod' 6 7interface UseFormValidationOptions<T extends z.ZodSchema> { 8 schema: T 9 onSubmit: (data: z.infer<T>) => Promise<void> 10} 11 12export function useFormValidation<T extends z.ZodSchema>({ 13 schema, 14 onSubmit 15}: UseFormValidationOptions<T>) { 16 const [errors, setErrors] = useState<Record<string, string>>({}) 17 const [isSubmitting, setIsSubmitting] = useState(false) 18 19 const validate = useCallback( 20 (data: unknown): z.infer<T> | null => { 21 const result = schema.safeParse(data) 22 23 if (!result.success) { 24 const fieldErrors: Record<string, string> = {} 25 result.error.errors.forEach(err => { 26 const path = err.path.join('.') 27 if (!fieldErrors[path]) { 28 fieldErrors[path] = err.message 29 } 30 }) 31 setErrors(fieldErrors) 32 return null 33 } 34 35 setErrors({}) 36 return result.data 37 }, 38 [schema] 39 ) 40 41 const handleSubmit = useCallback( 42 async (data: unknown) => { 43 const validated = validate(data) 44 if (!validated) return 45 46 setIsSubmitting(true) 47 try { 48 await onSubmit(validated) 49 } finally { 50 setIsSubmitting(false) 51 } 52 }, 53 [validate, onSubmit] 54 ) 55 56 const getFieldError = useCallback( 57 (field: string) => errors[field], 58 [errors] 59 ) 60 61 const clearErrors = useCallback(() => setErrors({}), []) 62 63 return { 64 errors, 65 isSubmitting, 66 validate, 67 handleSubmit, 68 getFieldError, 69 clearErrors 70 } 71}

Server Action Validation#

1// actions/posts.ts 2'use server' 3 4import { z } from 'zod' 5import { revalidatePath } from 'next/cache' 6import { prisma } from '@/lib/db' 7import { auth } from '@/lib/auth' 8import { createPostSchema } from '@/lib/validations/api' 9 10export async function createPost(formData: FormData) { 11 const { userId } = await auth() 12 if (!userId) { 13 return { error: 'Unauthorized' } 14 } 15 16 const rawData = { 17 title: formData.get('title'), 18 content: formData.get('content'), 19 published: formData.get('published') === 'true' 20 } 21 22 const result = createPostSchema.safeParse(rawData) 23 24 if (!result.success) { 25 return { 26 error: 'Validation failed', 27 fieldErrors: result.error.flatten().fieldErrors 28 } 29 } 30 31 const post = await prisma.post.create({ 32 data: { 33 ...result.data, 34 authorId: userId 35 } 36 }) 37 38 revalidatePath('/posts') 39 return { success: true, post } 40}

Environment Variable Validation#

1// lib/env.ts 2import { z } from 'zod' 3 4const envSchema = z.object({ 5 DATABASE_URL: z.string().url(), 6 NEXTAUTH_SECRET: z.string().min(32), 7 STRIPE_SECRET_KEY: z.string().startsWith('sk_'), 8 RESEND_API_KEY: z.string().startsWith('re_'), 9 NODE_ENV: z.enum(['development', 'production', 'test']).default('development') 10}) 11 12// Validate at startup 13export const env = envSchema.parse(process.env) 14 15// Type-safe environment access 16declare global { 17 namespace NodeJS { 18 interface ProcessEnv extends z.infer<typeof envSchema> {} 19 } 20}

Usage Instructions#

  1. Install Zod: npm install zod
  2. Create schema files: Organize schemas in lib/validations/
  3. Define schemas: Build composable, reusable schemas
  4. Validate inputs: Use safeParse for graceful error handling
  5. Export types: Use z.infer<typeof schema> for type inference

Best Practices#

  1. Reuse common schemas - Create shared schemas for emails, passwords, etc.
  2. Use safeParse - Avoid throwing errors in most cases
  3. Add custom messages - Provide user-friendly error messages
  4. Validate on server - Never trust client-side validation alone
  5. Type your inputs - Use z.infer to get TypeScript types
  6. Validate early - Catch errors before processing
  7. Transform data - Use .transform() to normalize input
  8. Test edge cases - Verify boundary conditions