I am a...
Learn more
How it worksPricingFAQ
Account
May 14, 2026 · 11 min read · Cadence Editorial

How to use Zod for API validation

zod api validation — How to use Zod for API validation
Photo by [Nemuel Sereti](https://www.pexels.com/@nemuel) on [Pexels](https://www.pexels.com/photo/computer-program-on-the-monitor-6424585/)

How to use Zod for API validation

To use Zod for API validation, define a schema with z.object(), parse incoming requests with safeParse(), and infer your TypeScript types directly from the schema using z.infer<typeof Schema>. One schema then powers your validator, your request type, and your OpenAPI doc. That single source of truth is the entire reason Zod won this category.

Why Zod owns API validation in 2026

TypeScript types disappear at runtime. The moment a JSON payload hits your handler, your beautiful interface CreateUser is gone, and what arrives is whatever the client felt like sending. Untrusted input remains the largest single source of production incidents we see across teams shipping fast: missing fields, wrong types, coerced strings, sneaky nulls. Zod closes that gap.

Two reasons Zod is the default in 2026:

  1. It infers TypeScript types from the schema. You write the validator once and your handler signature comes for free.
  2. The 4.x release is small (~2 KB gzipped for the core), fast (well under 0.1ms per typical request), and battle-tested. Zod ships north of 40 million npm downloads a week, and most modern frameworks already wire it in by name.

If you only adopt one library this quarter for a Node, Bun, or edge runtime API, this is the one.

Install and set up

npm install zod

Zod 4 expects TypeScript 5.5 or later, with "strict": true in your tsconfig.json. Without strict mode, you lose the inference guarantees that make the library worth using.

// tsconfig.json (the flags that matter)
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "moduleResolution": "Bundler"
  }
}
import { z } from "zod";

That's the entire setup. No build plugins, no decorators, no codegen step.

Define schemas for body, query, params, headers

Treat the four parts of an HTTP request as four separate schemas. Compose them at the route level.

// schemas/users.ts
import { z } from "zod";

export const CreateUserBody = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(120),
  role: z.enum(["owner", "member", "viewer"]).default("member"),
  marketingOptIn: z.boolean().optional(),
});

export const ListUsersQuery = z.object({
  // Query strings arrive as strings; coerce them.
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(25),
  search: z.string().trim().max(120).optional(),
});

export const UserParams = z.object({
  id: z.string().uuid(),
});

export const AuthHeaders = z.object({
  authorization: z.string().regex(/^Bearer .+/),
  "x-org-id": z.string().uuid(),
});

Two patterns earn their keep here. z.coerce.number() handles the fact that Express, Hono, and Next.js all hand you query strings as strings. z.string().trim() doubles as a transform; the parsed value already has whitespace removed, so your handler never has to remember.

Infer TypeScript types from your schemas

export type CreateUserBody = z.infer<typeof CreateUserBody>;
export type ListUsersQuery = z.infer<typeof ListUsersQuery>;

Now the handler signature is a one-liner:

function createUser(body: CreateUserBody) { /* fully typed */ }

When a schema has transforms or defaults, the input shape and the output shape diverge. Use z.input<typeof S> for what callers send, and z.output<typeof S> (the same as z.infer<typeof S>) for what your handler receives after parsing. This is the bit most blog posts skip, and it's exactly what bites teams when they pass defaults to a coerced query schema.

parse vs safeParse: pick the right pattern

parse throws a ZodError on failure. safeParse returns a discriminated result you can branch on without exceptions.

const parsed = CreateUserBody.safeParse(await req.json());
if (!parsed.success) {
  return Response.json(formatZodError(parsed.error), { status: 400 });
}
const body = parsed.data; // typed as CreateUserBody

Rule of thumb: use safeParse at every external boundary (HTTP handler, queue consumer, webhook). Use parse only inside trusted code where a thrown error is genuinely unrecoverable (a config loader at boot, a test fixture). Throwing inside a request handler works, but it pushes error formatting into your global error filter and tends to leak raw issue arrays to clients.

Format Zod errors for the client

A raw ZodError is dense, nested, and not safe to send as-is. Two production-friendly options:

Option A: a tiny formatter you own.

import { ZodError } from "zod";

export function formatZodError(error: ZodError) {
  return {
    type: "https://api.example.com/errors/validation",
    title: "Validation failed",
    status: 400,
    errors: error.issues.map((i) => ({
      field: i.path.join("."),
      code: i.code,
      message: i.message,
    })),
  };
}

That shape sticks close to RFC 7807 problem+json, which most API clients (and a few logging vendors) parse natively.

Option B: the zod-validation-error library.

import { fromZodError } from "zod-validation-error";

if (!parsed.success) {
  const friendly = fromZodError(parsed.error);
  return Response.json({ message: friendly.toString() }, { status: 400 });
}

zod-validation-error flattens nested issues into a single readable string per request. Use it when your API powers a UI that needs human-readable copy, not a structured error list.

Middleware integration across five frameworks

The same schema slots into every popular framework. The table below shows what changes; the schema doesn't.

FrameworkValidatorType InferenceOpenAPI Path
Next.js Route Handlersschema.safeParse(await req.json())z.infer<typeof Body>@asteasolutions/zod-to-openapi
tRPC.input(schema) on the procedureBuilt-in via the tRPC clienttrpc-openapi adapter
Expressvalidate(schema) middlewarez.infer plus a typed req helper@asteasolutions/zod-to-openapi
Fastifyfastify-type-provider-zodBuilt-in via the type provider@fastify/swagger + zod provider
Hono@hono/zod-validatorBuilt-in via c.req.valid@hono/zod-openapi

Next.js Route Handlers

// app/api/users/route.ts
import { CreateUserBody } from "@/schemas/users";
import { formatZodError } from "@/lib/format-zod-error";

export async function POST(req: Request) {
  const parsed = CreateUserBody.safeParse(await req.json());
  if (!parsed.success) {
    return Response.json(formatZodError(parsed.error), { status: 400 });
  }
  // parsed.data is fully typed as CreateUserBody
  const user = await db.user.create({ data: parsed.data });
  return Response.json(user, { status: 201 });
}

tRPC

import { z } from "zod";
import { publicProcedure, router } from "./trpc";

export const userRouter = router({
  create: publicProcedure
    .input(CreateUserBody)
    .mutation(({ input }) => db.user.create({ data: input })),
});

Validation, types, and client codegen all flow from .input(schema). There is no second step.

Express

import type { Request, Response, NextFunction } from "express";
import type { ZodSchema } from "zod";

export const validate =
  (schema: { body?: ZodSchema; query?: ZodSchema; params?: ZodSchema }) =>
  (req: Request, res: Response, next: NextFunction) => {
    for (const key of ["body", "query", "params"] as const) {
      const part = schema[key];
      if (!part) continue;
      const result = part.safeParse(req[key]);
      if (!result.success) {
        return res.status(400).json(formatZodError(result.error));
      }
      // overwrite with parsed/coerced value
      (req as any)[key] = result.data;
    }
    next();
  };

app.post("/users", validate({ body: CreateUserBody }), (req, res) => {
  // req.body is typed if you augment Express Request
});

Fastify

import Fastify from "fastify";
import { serializerCompiler, validatorCompiler, ZodTypeProvider } from "fastify-type-provider-zod";

const app = Fastify().withTypeProvider<ZodTypeProvider>();
app.setValidatorCompiler(validatorCompiler);
app.setSerializerCompiler(serializerCompiler);

app.post("/users", { schema: { body: CreateUserBody } }, async (req) => {
  // req.body is typed automatically
  return db.user.create({ data: req.body });
});

Hono

import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";

const app = new Hono();

app.post("/users", zValidator("json", CreateUserBody), (c) => {
  const body = c.req.valid("json"); // typed as CreateUserBody
  return c.json({ ok: true, body });
});

Hono's validator is the lowest-ceremony of the five, which is one of the reasons it's eaten Express's lunch on the edge.

Discriminated unions, transforms, refinements, brand types

Four Zod features that separate junior usage from senior usage.

Discriminated unions for variant payloads. When the shape depends on a type field, z.discriminatedUnion is faster and produces better error messages than z.union.

const Webhook = z.discriminatedUnion("type", [
  z.object({ type: z.literal("payment.succeeded"), id: z.string(), amount: z.number() }),
  z.object({ type: z.literal("payment.failed"),    id: z.string(), reason: z.string() }),
  z.object({ type: z.literal("subscription.canceled"), id: z.string(), at: z.coerce.date() }),
]);

Transforms for sanitization. They mutate the parsed value, which means your handler always receives the cleaned shape.

const Email = z.string().trim().toLowerCase().email();

Refinements for custom rules that pure schema can't express.

const Password = z.string().min(12).refine(
  (p) => /[A-Z]/.test(p) && /[0-9]/.test(p),
  { message: "Must include an uppercase letter and a digit" }
);

Brand types to stop ID mix-ups at the type level.

const UserId = z.string().uuid().brand<"UserId">();
const OrgId  = z.string().uuid().brand<"OrgId">();

type UserId = z.infer<typeof UserId>;
type OrgId  = z.infer<typeof OrgId>;

function loadUser(id: UserId) { /* ... */ }

const orgId = OrgId.parse(req.params.id);
loadUser(orgId); // compile error: Argument of type 'OrgId' is not assignable to parameter of type 'UserId'

A brand is a phantom type. At runtime it's still a string. At compile time, the TypeScript checker refuses to mix them. This single pattern eliminates an entire class of "pass the wrong ID to the wrong query" bugs that show up in code reviews. While we're on the subject, the same care that produces strong validation produces strong code reviews; if your team is shipping AI-assisted PRs, our notes on doing code reviews effectively in 2026 walk through where humans should still slow down.

Generate OpenAPI from the same schemas

Hand-written OpenAPI drifts from code within a sprint. Zod's ecosystem turns the schema into the doc.

For Express and Next, @asteasolutions/zod-to-openapi registers schemas and emits an OpenAPI 3.1 document:

import { OpenAPIRegistry, OpenApiGeneratorV31 } from "@asteasolutions/zod-to-openapi";

const registry = new OpenAPIRegistry();
registry.register("CreateUserBody", CreateUserBody);
registry.registerPath({
  method: "post",
  path: "/users",
  request: { body: { content: { "application/json": { schema: CreateUserBody } } } },
  responses: { 201: { description: "Created" } },
});

const doc = new OpenApiGeneratorV31(registry.definitions).generateDocument({
  openapi: "3.1.0",
  info: { title: "API", version: "1.0.0" },
});

Hono and tRPC have first-party adapters (@hono/zod-openapi, trpc-openapi) that follow the same pattern. The point isn't the library; it's that your API contract, your validator, and your handler types now live in one file. When the schema changes, every downstream artifact changes with it.

This is the same instinct that makes API versioning correct in 2026 tractable: pick one source of truth, then make the tooling enforce it. Pair Zod with the rate-limiting and observability story in our guides on REST API design and rate limiting an API, and you have most of an enterprise-grade API surface in a weekend.

If you'd rather have a senior engineer do the migration than read your way to it, audit your stack with the Cadence Ship-or-Skip tool for a 5-minute honest grade on whether Zod plus OpenAPI generation is the right next move for you.

Common pitfalls

  • Forgetting z.coerce on numeric query params. ?page=2 fails validation against z.number() because Express hands you a string. Use z.coerce.number().
  • Logging the raw ZodError to clients. It contains internal paths and codes you don't want exposed. Always run it through a formatter.
  • Calling parse in a hot loop without try/catch. Throwing on every invalid record from a Kafka topic will pin a CPU. Use safeParse and route bad records to a dead-letter queue.
  • Drifting hand-typed interfaces. If you find yourself maintaining a TypeScript type next to a Zod schema, delete the type and use z.infer. The duplication is the whole bug.
  • Stuffing transforms into refinements. refine returns a boolean. transform returns the new value. Mixing them produces confusing error messages and broken inference.

When you can skip Zod

Be honest about scope. Three cases where Zod adds friction without payoff:

  • Internal RPC where both sides ship as one binary (a Tauri desktop app, a Cloudflare Worker that only its own UI calls). TypeScript types are sufficient because there's no untrusted boundary.
  • Static config files parsed at boot and never touched again. A single JSON.parse plus a quick assertion is fine.
  • Throwaway scripts that read a CSV you control. Save Zod for code that touches users.

If you're shipping anything with a public API, a webhook receiver, or a background job that reads from a queue, Zod earns its keep on day one.

The Cadence connection

Wiring Zod through five frameworks, branding your IDs, and generating OpenAPI from the same source isn't hard. It's just methodical, and most teams under-staff it because the founder is busy and the tech-lead is in meetings. A senior Cadence engineer ($1,500/week) typically lands a Zod-everywhere migration on a mid-sized API in 5 to 8 days, including OpenAPI generation, error formatting, and CI checks that fail when a route lacks a schema. Every Cadence engineer is AI-native by default, vetted on Cursor and Claude Code fluency in a voice interview before they unlock bookings, so they ship the schemas with AI-pair-programmed test coverage out of the gate.

Try it. Book a senior engineer on Cadence for a 48-hour free trial. They'll bring your API up to a Zod-validated, OpenAPI-documented baseline by Friday, with daily ratings and weekly billing. No notice period.

Steps

  1. Install Zod. Run npm install zod. Confirm TypeScript 5.5+ and "strict": true in tsconfig.json.
  2. Define a schema. In schemas/users.ts, write export const CreateUserBody = z.object({...}) for body, query, params, and headers as separate exports.
  3. Parse the request. In your handler, call CreateUserBody.safeParse(await req.json()) and branch on parsed.success. Return a 400 with a formatted error if it fails.
  4. Infer the TypeScript type. Add export type CreateUserBody = z.infer<typeof CreateUserBody> and use it for handler arguments, downstream functions, and shared client code.
  5. Wire up middleware. Add validate(schema) for Express, zValidator("json", schema) for Hono, .input(schema) for tRPC, the type provider for Fastify, or call safeParse inline for Next.js Route Handlers.
  6. Generate OpenAPI. Register each schema with @asteasolutions/zod-to-openapi (or the adapter for your framework) and serve the generated document at /openapi.json. Your validator, your types, and your docs now ship from the same source.

FAQ

Should I use Zod or Yup in 2026?

Zod. It infers TypeScript types directly from the schema; Yup needs separate type files and is slower to maintain on a typed codebase. Yup still has a place in pure JavaScript projects, but if you're on TypeScript, Zod's inference removes the duplication that Yup forces you to maintain by hand.

What changed in Zod 4?

A smaller core (~2 KB gzipped), faster parsing on common shapes, a unified .check() API for combined refinements and transforms, cleaner issue codes for nicer error messages, and built-in JSON Schema conversion. Most v3 code runs on v4 with no changes; the migration guide in the Zod docs covers the few breaking renames.

Does Zod hurt API performance?

Per-request validation runs well under a millisecond on typical payloads, far below network and database latency. Only deeply nested 1MB+ JSON starts to register, and even then a database round-trip dominates. If you measure and find Zod in your top-three flame-graph items, you're either parsing huge payloads or doing it on every middleware hop; cache the result on the request object.

How do I share Zod schemas between client and server?

Put them in a shared package (a TypeScript monorepo with pnpm workspaces, or a published @org/schemas package). Both the client and the server import the same z.object, both sides get the same z.infer type, and your form library (React Hook Form, TanStack Form) can re-use the same schema for client-side validation.

Can Zod replace OpenAPI?

No. Zod replaces hand-written OpenAPI by generating it. Tools like @asteasolutions/zod-to-openapi, @hono/zod-openapi, or trpc-openapi turn your schemas into the spec, so docs never drift from validation. Clients in other languages still consume the OpenAPI document; you just stop hand-editing it.

All posts