CentercodePorygon
DittoPorygonAPI

Validation

Zod schemas for runtime input validation

SafeReliable

Overview

API inputs are validated automatically via spec schemas. Define queryParams/requestBody in your RouteSpec, and withAuth validates them before your handler runs.

What it is

Zod schema validation utilities that parse request bodies, query params, and route params with automatic error formatting.

Why we use it

Type safety at runtime, clear validation errors, consistent error responses, and protection against malformed input.

When to use

Every API route that accepts input. Never trust unvalidated data.

Key Features

  • Spec-based validation - define in RouteSpec, auto-validated
  • Handler receives pre-validated query/body/params
  • Schemas drive OpenAPI docs at /admin/api-docs
  • Manual validators available for edge cases
  • commonSchemas - reusable patterns (email, uuid, pagination)

Quick Start

Basic Validation

Validating a POST request body.

// Spec-based validation (recommended)
import { z } from 'zod';
import { withApiLogging } from '@/lib/api-logging';
import { withAuth } from '@/lib/api-auth';
import type { RouteSpec } from '@/lib/api-docs';

export const spec = {
  POST: {
    summary: 'Create user',
    tags: ['Users'],
    requestBody: z.object({
      email: z.string().email(),
      name: z.string().min(1).max(100),
    }),
    response: userSchema,
    requiresAuth: true,
  },
} satisfies RouteSpec;

export const POST = withApiLogging(
  withAuth({ scope: 'program' }, spec.POST, async (_req, { body }) => {
    const { email, name } = body; // Already validated!
    // ...
  })
);

Patterns

Query Parameters

Preferred pattern using spec schemas.

// Query params via spec (recommended)
import { z } from 'zod';
import type { RouteSpec } from '@/lib/api-docs';

export const spec = {
  GET: {
    summary: 'List items',
    tags: ['Items'],
    queryParams: z.object({
      page: z.coerce.number().min(1).default(1),
      limit: z.coerce.number().min(1).max(100).default(20),
      search: z.string().optional(),
    }),
    response: z.object({ items: z.array(itemSchema), total: z.number() }),
    requiresAuth: true,
  },
} satisfies RouteSpec;

export const GET = withApiLogging(
  withAuth({ scope: 'project' }, spec.GET, async (_req, { query }) => {
    const { page, limit, search } = query; // Pre-validated!
    // ...
  })
);

Manual Validation (Fallback)

For edge cases outside withAuth wrapper.

// Manual validation (for edge cases outside withAuth)
import { validateRequest, validateBody } from '@/lib/validation';
import { NextRequest } from 'next/server';

// Option 1: validateRequest (async, parses JSON)
const validation = await validateRequest(request, schema);
if (!validation.success) return validation.error;
const { field1, field2 } = validation.data;

// Option 2: validateBody (sync, for pre-parsed bodies)
const body = await request.json();
const validation = validateBody(body, schema);
if (!validation.success) return validation.error;

// Note: Prefer spec-based validation via withAuth
// Manual validation is for special cases only

Nested Objects

Validating complex nested structures.

// Nested object validation
const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string(),
});

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  address: AddressSchema,
  tags: z.array(z.string()).default([]),
});

Array Validation

Validating arrays with item schemas.

// Array validation with item schemas
const BulkCreateSchema = z.object({
  items: z.array(
    z.object({
      name: z.string().min(1),
      quantity: z.number().int().positive(),
    })
  ).min(1).max(100),
});

// With transforms
const IdsSchema = z.object({
  ids: z.string().transform(s => s.split(',')).pipe(
    z.array(z.string().uuid())
  ),
});

Watch Out

Using request data without validation

Don't

// Parsing JSON without validation
const { userId, role } = await request.json();
await updateUser(userId, { role }); // Dangerous!

Do

// Use spec-based validation
withAuth({ scope: 'program' }, spec.PATCH, async (_req, { body }) => {
  const { userId, role } = body; // Pre-validated via spec!
  await updateUser(userId, { role }); // Safe!
});

Making schemas too permissive with optional fields

Don't

// Overly permissive or undocumented schemas
const schema = z.object({
  data: z.any(), // Never use z.any()!
  metadata: z.record(z.unknown()), // Missing .describe()
});

Do

// Explicit schema with typed or described fields
const schema = z.object({
  data: z.object({
    name: z.string(),
    value: z.number(),
  }),
  // For truly dynamic fields, add .describe()
  metadata: z.record(z.string(), z.unknown())
    .describe('User-defined key-value pairs'),
});
  • Not providing clear error messages
  • Not using Zod type inference for handlers

Related

API Architecture

API patterns

Error Handling

Error handling patterns

Forms (Ditto)