Every API you build has one thing in common: it accepts input from the outside world. And the outside world cannot be trusted.
Whether it's a malformed JSON body, a missing required field, or an age field set to "banana", bad input is the #1 source of bugs, crashes, and security vulnerabilities in backend applications.
In this guide, you'll learn how to use Zod — the TypeScript-first schema validation library — to validate every API request before it reaches your business logic. As of February 2026, Zod v3.24 remains the most popular runtime validation library in the Node.js ecosystem, with over 40 million weekly npm downloads.
Why Zod for API Validation?
You might be thinking: "I already use TypeScript. Isn't that enough?"
No. TypeScript types are erased at runtime. When a POST request hits your Express server, TypeScript has zero idea what's inside req.body. It could be anything.
Zod solves this by giving you:
- Runtime validation — actually checks the data at runtime
- Type inference — automatically generates TypeScript types from schemas
- Detailed error messages — tells users exactly what's wrong
- Zero dependencies — lightweight and fast
Setup
Let's start a new project. We'll use Node.js 22 LTS with Express 5:
mkdir zod-api-demo && cd zod-api-demo
npm init -y
npm install express zod
npm install -D typescript @types/express @types/node tsx
npx tsc --init
Step 1: Define Your Schemas
Create a schemas.ts file:
import { z } from 'zod';
export const createUserSchema = z.object({
body: z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
age: z.number().int().min(13, 'Must be at least 13 years old').optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
}),
});
export const updateUserSchema = z.object({
params: z.object({
id: z.string().uuid('Invalid user ID format'),
}),
body: z.object({
name: z.string().min(2).optional(),
email: z.string().email().optional(),
age: z.number().int().min(13).optional(),
role: z.enum(['user', 'admin', 'moderator']).optional(),
}),
});
export const listUsersSchema = z.object({
query: z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
role: z.enum(['user', 'admin', 'moderator']).optional(),
search: z.string().max(100).optional(),
}),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export type UpdateUserInput = z.infer<typeof updateUserSchema>;
export type ListUsersInput = z.infer<typeof listUsersSchema>;
Step 2: Build the Validation Middleware
A reusable middleware that validates any request against a Zod schema:
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
export const validate =
(schema: AnyZodObject) =>
(req: Request, res: Response, next: NextFunction) => {
try {
schema.parse({
body: req.body,
query: req.query,
params: req.params,
});
next();
} catch (error) {
if (error instanceof ZodError) {
const formattedErrors = error.errors.map((err) => ({
field: err.path.join('.'),
message: err.message,
}));
return res.status(400).json({
status: 'error',
message: 'Validation failed',
errors: formattedErrors,
});
}
next(error);
}
};
Step 3: Wire It Into Your Routes
import express from 'express';
import { validate } from './middleware/validate';
import { createUserSchema, updateUserSchema, listUsersSchema } from './schemas';
const app = express();
app.use(express.json());
app.post('/api/users', validate(createUserSchema), (req, res) => {
const { name, email, age, role } = req.body;
res.status(201).json({
status: 'success',
data: { id: crypto.randomUUID(), name, email, age, role },
});
});
app.get('/api/users', validate(listUsersSchema), (req, res) => {
const { page, limit, role, search } = req.query as any;
const offset = (page - 1) * limit;
res.json({
status: 'success',
data: [],
pagination: { page, limit, offset },
});
});
app.patch('/api/users/:id', validate(updateUserSchema), (req, res) => {
const { id } = req.params;
const updates = req.body;
res.json({ status: 'success', data: { id, ...updates } });
});
app.listen(3000, () => console.log('API running on http://localhost:3000'));
Step 4: Test the Validation
# Missing required fields
curl -X POST http://localhost:3000/api/users \
-H 'Content-Type: application/json' \
-d '{}'
Response:
{
"status": "error",
"message": "Validation failed",
"errors": [
{ "field": "body.name", "message": "Required" },
{ "field": "body.email", "message": "Required" }
]
}
Advanced: Reusable Schema Patterns
As your API grows, extract repeating patterns:
import { z } from 'zod';
export const paginationQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sortBy: z.string().optional(),
order: z.enum(['asc', 'desc']).default('desc'),
});
export const idParam = z.object({
id: z.string().uuid('Invalid ID format'),
});
export const listPostsSchema = z.object({
query: paginationQuery.extend({
status: z.enum(['draft', 'published', 'archived']).optional(),
authorId: z.string().uuid().optional(),
}),
});
Performance Tips
- Create schemas once, reuse everywhere — don't define schemas inside handlers
-
Use
.passthrough()sparingly — strict schemas are faster -
Prefer
z.coercefor query params — Express delivers everything as strings
Zod's overhead is typically under 0.1ms per validation — negligible compared to database queries.
Wrapping Up
You've built schema definitions that serve as both validation rules and TypeScript types, a reusable middleware, structured error responses, and composable patterns that scale.
The best part? Your route handlers never worry about bad data again. If the code runs, the input is valid.
Start validating. Your future self will thank you.
Building APIs? 1xAPI offers ready-to-use API services so you can focus on your product instead of infrastructure.
Top comments (0)