Featured

Zod Validation in Next.js: Complete Guide from Beginner to Expert

T
Team
·30 min read
#nextjs#zod#validation#typescript#api routes#server actions

Zod Validation in Next.js: Complete Guide from Beginner to Expert


Zod validation in Next.js provides type-safe validation for API routes, Server Actions, forms, and middleware. This comprehensive guide covers everything from basic setup to expert-level patterns for production applications.


Why Zod in Next.js?


Next.js applications benefit from Zod validation because:

  • API Route Safety: Validate request data before processing
  • Server Actions: Type-safe form submissions
  • Middleware: Validate and transform requests
  • Type Inference: Automatic TypeScript types
  • Runtime Safety: Catch errors before they reach your business logic

  • Installation


    bash
    1npm install zod
    2# For form validation
    3npm install react-hook-form @hookform/resolvers
    4# For API validation utilities
    5npm install zod-form-data

    API Route Validation


    Basic API Route Validation


    typescript(35 lines, showing 15)
    1// app/api/users/route.ts
    2import { NextRequest, NextResponse } from 'next/server';
    3import { z } from 'zod';
    4 
    5const createUserSchema = z.object({
    6 name: z.string().min(1, 'Name is required'),
    7 email: z.string().email('Invalid email'),
    8 age: z.number().int().min(18, 'Must be at least 18')
    9});
    10 
    11export async function POST(request: NextRequest) {
    12 try {
    13 const body = await request.json();
    14
    15 // Validate request body

    Advanced API Route Validation


    typescript(82 lines, showing 15)
    1// app/api/users/[id]/route.ts
    2import { NextRequest, NextResponse } from 'next/server';
    3import { z } from 'zod';
    4 
    5// Query parameter validation
    6const getUserParamsSchema = z.object({
    7 id: z.string().uuid('Invalid user ID')
    8});
    9 
    10// Query string validation
    11const getUserQuerySchema = z.object({
    12 include: z.enum(['posts', 'comments', 'all']).optional(),
    13 page: z.string().transform(Number).pipe(z.number().int().positive()).optional(),
    14 limit: z.string().transform(Number).pipe(z.number().int().positive().max(100)).optional()
    15});

    Form Data Validation


    typescript(50 lines, showing 15)
    1// app/api/upload/route.ts
    2import { NextRequest, NextResponse } from 'next/server';
    3import { z } from 'zod';
    4 
    5const uploadSchema = z.object({
    6 title: z.string().min(1, 'Title is required'),
    7 description: z.string().max(500).optional(),
    8 category: z.enum(['image', 'video', 'document']),
    9 tags: z.array(z.string()).max(10).optional()
    10});
    11 
    12export async function POST(request: NextRequest) {
    13 try {
    14 const formData = await request.formData();
    15

    Server Actions Validation


    Basic Server Action


    typescript(42 lines, showing 15)
    1// app/actions/user-actions.ts
    2'use server';
    3 
    4import { z } from 'zod';
    5import { revalidatePath } from 'next/cache';
    6 
    7const createUserSchema = z.object({
    8 name: z.string().min(1, 'Name is required'),
    9 email: z.string().email('Invalid email'),
    10 password: z.string().min(8, 'Password must be at least 8 characters')
    11});
    12 
    13export async function createUser(formData: FormData) {
    14 try {
    15 const rawData = {

    Advanced Server Action with Error Handling


    typescript(59 lines, showing 15)
    1// app/actions/user-actions.ts
    2'use server';
    3 
    4import { z } from 'zod';
    5import { revalidatePath } from 'next/cache';
    6 
    7const updateUserSchema = z.object({
    8 id: z.string().uuid(),
    9 name: z.string().min(1).optional(),
    10 email: z.string().email().optional(),
    11 bio: z.string().max(500).optional(),
    12 website: z.string().url().optional().or(z.literal(''))
    13}).refine(
    14 (data) => Object.keys(data).length > 1, // At least one field besides id
    15 { message: 'At least one field must be provided' }

    Using Server Actions in Forms


    typescript(55 lines, showing 15)
    1// app/components/user-form.tsx
    2'use client';
    3 
    4import { useFormState, useFormStatus } from 'react-dom';
    5import { updateUser, type ActionResult } from '@/app/actions/user-actions';
    6 
    7function SubmitButton() {
    8 const { pending } = useFormStatus();
    9 return (
    10 <button type="submit" disabled={pending}>
    11 {pending ? 'Updating...' : 'Update User'}
    12 </button>
    13 );
    14}
    15 

    Middleware Validation


    Request Validation in Middleware


    typescript(53 lines, showing 15)
    1// middleware.ts
    2import { NextResponse } from 'next/server';
    3import type { NextRequest } from 'next/server';
    4import { z } from 'zod';
    5 
    6const apiKeySchema = z.string().min(1);
    7 
    8export function middleware(request: NextRequest) {
    9 // Validate API key in headers
    10 if (request.nextUrl.pathname.startsWith('/api/protected')) {
    11 const apiKey = request.headers.get('x-api-key');
    12
    13 try {
    14 apiKeySchema.parse(apiKey);
    15 } catch (error) {

    Form Validation with React Hook Form


    Client-Side Form Validation


    typescript(82 lines, showing 15)
    1// app/components/contact-form.tsx
    2'use client';
    3 
    4import { useForm } from 'react-hook-form';
    5import { zodResolver } from '@hookform/resolvers/zod';
    6import { z } from 'zod';
    7import { useState } from 'react';
    8 
    9const contactSchema = z.object({
    10 name: z.string().min(1, 'Name is required'),
    11 email: z.string().email('Invalid email'),
    12 subject: z.string().min(1, 'Subject is required'),
    13 message: z.string().min(10, 'Message must be at least 10 characters'),
    14 phone: z.string().regex(/^\+?[1-9]\d{1,14}$/, 'Invalid phone number').optional()
    15});

    Advanced Validation Patterns


    Environment Variable Validation


    typescript(22 lines, showing 15)
    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 NEXTAUTH_URL: z.string().url(),
    8 SMTP_HOST: z.string().optional(),
    9 SMTP_PORT: z.string().transform(Number).pipe(z.number().int()).optional(),
    10 SMTP_USER: z.string().optional(),
    11 SMTP_PASSWORD: z.string().optional()
    12});
    13 
    14export const env = envSchema.parse({
    15 DATABASE_URL: process.env.DATABASE_URL,

    Pagination Schema


    typescript
    1// lib/schemas/pagination.ts
    2import { z } from 'zod';
    3 
    4export const paginationSchema = z.object({
    5 page: z.string().transform(Number).pipe(z.number().int().positive()).default('1'),
    6 limit: z.string().transform(Number).pipe(z.number().int().positive().max(100)).default('10'),
    7 sortBy: z.string().optional(),
    8 sortOrder: z.enum(['asc', 'desc']).default('desc')
    9});
    10 
    11export type PaginationParams = z.infer<typeof paginationSchema>;

    Search and Filter Schema


    typescript(21 lines, showing 15)
    1// lib/schemas/search.ts
    2import { z } from 'zod';
    3 
    4export const searchSchema = z.object({
    5 query: z.string().min(1, 'Search query is required'),
    6 category: z.enum(['all', 'posts', 'users', 'comments']).optional(),
    7 dateFrom: z.string().date().optional(),
    8 dateTo: z.string().date().optional(),
    9 tags: z.array(z.string()).optional()
    10}).refine(
    11 (data) => {
    12 if (data.dateFrom && data.dateTo) {
    13 return new Date(data.dateTo) >= new Date(data.dateFrom);
    14 }
    15 return true;

    File Upload Validation


    typescript(18 lines, showing 15)
    1// lib/schemas/upload.ts
    2import { z } from 'zod';
    3 
    4const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
    5const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
    6 
    7export const imageUploadSchema = z.object({
    8 file: z
    9 .instanceof(File)
    10 .refine((file) => file.size <= MAX_FILE_SIZE, {
    11 message: 'File size must be less than 5MB'
    12 })
    13 .refine((file) => ACCEPTED_IMAGE_TYPES.includes(file.type), {
    14 message: 'Only JPEG, PNG, and WebP images are allowed'
    15 }),

    Real-World Examples


    Complete CRUD API with Validation


    typescript(70 lines, showing 15)
    1// app/api/posts/route.ts
    2import { NextRequest, NextResponse } from 'next/server';
    3import { z } from 'zod';
    4 
    5const createPostSchema = z.object({
    6 title: z.string().min(1, 'Title is required').max(200),
    7 content: z.string().min(10, 'Content must be at least 10 characters'),
    8 published: z.boolean().default(false),
    9 tags: z.array(z.string()).max(10).optional(),
    10 categoryId: z.string().uuid().optional()
    11});
    12 
    13const updatePostSchema = createPostSchema.partial().refine(
    14 (data) => Object.keys(data).length > 0,
    15 { message: 'At least one field must be provided' }

    Authentication with Validation


    typescript(56 lines, showing 15)
    1// app/api/auth/register/route.ts
    2import { NextRequest, NextResponse } from 'next/server';
    3import { z } from 'zod';
    4 
    5const registerSchema = z.object({
    6 name: z.string().min(1, 'Name is required'),
    7 email: z.string().email('Invalid email'),
    8 password: z
    9 .string()
    10 .min(8, 'Password must be at least 8 characters')
    11 .regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
    12 .regex(/[a-z]/, 'Password must contain at least one lowercase letter')
    13 .regex(/[0-9]/, 'Password must contain at least one number'),
    14 confirmPassword: z.string()
    15}).refine(

    Best Practices


    1. Centralized Schema Definitions


    typescript
    1// lib/schemas/user.ts
    2import { z } from 'zod';
    3 
    4export const userSchema = z.object({
    5 id: z.string().uuid(),
    6 name: z.string().min(1),
    7 email: z.string().email(),
    8 createdAt: z.date()
    9});
    10 
    11export const createUserSchema = userSchema.omit({ id: true, createdAt: true });
    12export const updateUserSchema = createUserSchema.partial();

    2. Reusable Validation Utilities


    typescript(30 lines, showing 15)
    1// lib/utils/validation.ts
    2import { z } from 'zod';
    3import { NextResponse } from 'next/server';
    4 
    5export function validateRequest<T>(
    6 schema: z.ZodSchema<T>,
    7 data: unknown
    8): { success: true; data: T } | { success: false; response: NextResponse } {
    9 try {
    10 const validatedData = schema.parse(data);
    11 return { success: true, data: validatedData };
    12 } catch (error) {
    13 if (error instanceof z.ZodError) {
    14 return {
    15 success: false,

    3. Type-Safe API Responses


    typescript
    1// lib/types/api.ts
    2import { z } from 'zod';
    3 
    4export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) =>
    5 z.object({
    6 success: z.boolean(),
    7 data: dataSchema.optional(),
    8 errors: z.array(z.object({
    9 field: z.string(),
    10 message: z.string()
    11 })).optional()
    12 });
    13 
    14export type ApiResponse<T> = z.infer<ReturnType<typeof apiResponseSchema<z.ZodType<T>>>>;

    Expert Patterns


    Schema Composition and Reusability


    typescript(21 lines, showing 15)
    1// Base schemas
    2const baseEntitySchema = z.object({
    3 id: z.string().uuid(),
    4 createdAt: z.date(),
    5 updatedAt: z.date()
    6});
    7 
    8const timestampsSchema = z.object({
    9 createdAt: z.date(),
    10 updatedAt: z.date()
    11});
    12 
    13// Compose schemas
    14const userBaseSchema = z.object({
    15 name: z.string(),

    Dynamic Schema Generation


    typescript
    1function createFilterSchema<T extends z.ZodTypeAny>(itemSchema: T) {
    2 return z.object({
    3 page: z.number().int().positive().default(1),
    4 limit: z.number().int().positive().max(100).default(10),
    5 filters: z.record(z.unknown()).optional(),
    6 sort: z.object({
    7 field: z.string(),
    8 order: z.enum(['asc', 'desc'])
    9 }).optional()
    10 });
    11}

    Validation Middleware Pattern


    typescript(36 lines, showing 15)
    1// lib/middleware/validation.ts
    2import { z } from 'zod';
    3import { NextRequest, NextResponse } from 'next/server';
    4 
    5export function withValidation<T>(
    6 schema: z.ZodSchema<T>,
    7 handler: (req: NextRequest, data: T) => Promise<NextResponse>
    8) {
    9 return async (req: NextRequest) => {
    10 try {
    11 const body = await req.json();
    12 const validatedData = schema.parse(body);
    13 return handler(req, validatedData);
    14 } catch (error) {
    15 if (error instanceof z.ZodError) {

    Conclusion


    Zod validation in Next.js provides a robust foundation for building type-safe, production-ready applications. From API routes to Server Actions, middleware to forms, Zod ensures your data is validated at every layer.


    Key takeaways:

  • Validate all API route inputs
  • Use Server Actions with Zod for type-safe form handling
  • Validate environment variables at startup
  • Create reusable schema patterns
  • Handle errors gracefully with proper error responses
  • Leverage TypeScript type inference for better DX

  • Start with basic validation and gradually adopt more advanced patterns as your application grows in complexity.


    Enjoyed this article?

    Support our work and help us create more free content for developers.

    Stay Updated

    Get the latest articles and updates delivered to your inbox.