Type-safe APIs with Zod
Parse incoming JSON with a Zod schema before touching your database. Return structured error responses with the appropriate HTTP status codes. This pattern prevents entire classes of runtime bugs.
Building a REST API with Validation
Building a public REST API involves more than just reading request parameters; you must actively defend your database against malformed payloads, injection scripts, and illegal property types. Running queries directly with unvalidated input can corrupt database states or crash execution pipelines. In this lesson, we will cover how to implement validation schemas using Zod, parse incoming request bodies safely, output structured, actionable error responses to API clients, and validate dynamic query strings.
1. Why Validate Request Payloads at the API Boundary?
API validation acts as a security perimeter. By rejecting malformed data at the entrance of your Route Handler:
- You prevent SQL injection or database write crashes.
- You enforce business constraints (e.g. email verification formats, minimum password lengths).
- You keep database engines lean by discarding redundant or unexpected parameters before queries compile.
- You provide API consumers with precise, field-specific error reports to assist client-side debugging.
2. Installing and Configuring Zod
Zod is a TypeScript-first schema declaration and validation library. It is the industry standard for Next.js validation due to its clean syntax and automatic type inference.
Install Zod inside your workspace:
npm install zod
3. Defining Zod Validation Schemas
Declare a schema that reflects your expected request body payload. For example, a validation schema for a new user registration endpoint looks like this:
// lib/validations/user.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.string().email({ message: 'Invalid email address format' }),
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters long' })
.max(50, { message: 'Name must not exceed 50 characters' }),
age: z
.number()
.int()
.min(18, { message: 'Users must be 18 years or older' })
.optional(),
});
// Infer TypeScript type definitions directly from the schema
export type CreateUserInput = z.infer<typeof createUserSchema>;
4. Parsing Request Bodies and Handling Validation Failures
Inside your Route Handler, read the request payload and call safeParse (or safeParseAsync) to evaluate the data. Unlike parse() which throws errors that crash execution if validation fails, safeParse() returns an object indicating success or failure status:
// app/api/users/route.ts
import { NextResponse } from 'next/server';
import { createUserSchema } from '@/lib/validations/user';
export async function POST(request: Request) {
try {
const rawData = await request.json();
// Validate request body
const result = createUserSchema.safeParse(rawData);
// If validation fails, return structured errors with 400 Bad Request status
if (!result.success) {
// Map Zod errors into a clean, client-friendly format
const formattedErrors = result.error.format();
return NextResponse.json(
{ error: 'Validation failed', details: formattedErrors },
{ status: 400 }
);
}
// result.data contains type-safe variables containing ONLY the validated properties
const { email, name, age } = result.data;
// Execute database save action here...
return NextResponse.json({ email, name, age }, { status: 201 });
} catch (error) {
return NextResponse.json({ error: 'Failed to process request body' }, { status: 400 });
}
}
5. Validating Dynamic GET Query Parameters
You can apply the exact same schema verification patterns to validate search queries (like page limits or sorting parameters) inside GET requests:
// app/api/articles/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
const articleQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
page: z.coerce.number().int().min(1).default(1),
category: z.enum(['tech', 'design', 'business']).optional(),
});
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
// Convert URLSearchParams iterator into a plain object
const queryParams = Object.fromEntries(searchParams.entries());
// Validate and parse parameters (z.coerce converts string types to numbers automatically)
const result = articleQuerySchema.safeParse(queryParams);
if (!result.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: result.error.format() },
{ status: 400 }
);
}
const { limit, page, category } = result.data;
// Query database using safe values...
return NextResponse.json({ limit, page, category });
}
6. Structuring Clean Error Responses
Returning Zod's raw error objects can be overly detailed. It is best practice to format errors into a flat structure that client-side forms can map directly to visual inputs:
// utils/validation-errors.ts
import { ZodError } from 'zod';
export function formatZodErrors(error: ZodError) {
return error.issues.map((issue) => ({
field: issue.path.join('.'),
message: issue.message,
}));
}
Use this helper inside your endpoint handlers:
if (!result.success) {
const details = formatZodErrors(result.error);
return NextResponse.json({ error: 'Validation failed', details }, { status: 400 });
}
7. Defensive Parsing for Empty JSON Requests
If a client submits a POST request with an empty body or skips setting headers (e.g. omitting Content-Type: application/json), calling request.json() directly throws a parser crash error before Zod can run.
Always guard request body parsing inside a try-catch block to return clean status reports:
let rawData;
try {
rawData = await request.json();
} catch (err) {
return NextResponse.json({ error: 'Malformed JSON payload body.' }, { status: 400 });
}
8. Deep Schema Nesting Validation
Zod allows schemas to nest other schemas. This is ideal for validating complex records, like an invoice object containing an array of line items:
const lineItemSchema = z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1),
price: z.number().positive(),
});
const invoiceSchema = z.object({
customerId: z.string().uuid(),
items: z.array(lineItemSchema).nonempty({ message: 'Invoices must include at least one item.' }),
});
9. Common Gotchas
-
Using parse() instead of safeParse(): Calling
schema.parse()directly on invalid data throws a runtime error. If not caught, this crashes the endpoint wrapper, returning an unhandled 500 Server Error instead of a descriptive 400 Bad Request. -
Type Casting URL Strings with z.coerce: Search parameters from
URLSearchParamsare always string types. If you validate page numbers or limits without usingz.coerce(e.g., usingz.number()), Zod will reject the payload because it expects a number and receives a string. -
Exposing Sensitive Fields: When using
request.json()directly, clients can inject arbitrary fields (likerole: 'ADMIN') into your database queries. Ensure you only destruct validated parameters fromresult.datainstead of forwarding the raw body object directly to database writers.
Key Takeaways
- Always validate client-facing data inputs at the API boundary to safeguard database records.
- Leverage Zod's
z.inferto inherit TypeScript typings directly from validation schemas. - Use
safeParse()rather thanparse()to handle errors gracefully. - Apply
z.coerceto handle parsing string query parameters into numbers or booleans inside GET requests. - Map Zod issues into flat error arrays to assist client-side form validations.
Validating standard user inputs handles user actions and registrations. But APIs also receive programmatic notifications from third-party systems. In the next lesson, we will cover Webhooks & Background Jobs, learning how to verify remote payload signatures and offload slow queries to background tasks.