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#
- Install Zod:
npm install zod - Create schema files: Organize schemas in
lib/validations/ - Define schemas: Build composable, reusable schemas
- Validate inputs: Use
safeParsefor graceful error handling - Export types: Use
z.infer<typeof schema>for type inference
Best Practices#
- Reuse common schemas - Create shared schemas for emails, passwords, etc.
- Use safeParse - Avoid throwing errors in most cases
- Add custom messages - Provide user-friendly error messages
- Validate on server - Never trust client-side validation alone
- Type your inputs - Use
z.inferto get TypeScript types - Validate early - Catch errors before processing
- Transform data - Use
.transform()to normalize input - Test edge cases - Verify boundary conditions
Related Patterns#
- Forms - Form handling with validation
- Error Handling - API error responses
- Server Actions - Validating mutations