DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Zod Validation Patterns for Next.js: API Routes, Forms, and External APIs

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)