Type-Safe API Routes in Next.js 13
TL;DR — Validate input with Zod at the request boundary, never trust the type / Infer client-side types from the server-side schema, not the other way / The app router’s
route.tshandlers and the legacypages/api/model both work; pick one per project.
The single biggest lie in a TypeScript codebase is the type signature of an HTTP request handler. You write req: Request<{ id: string }, {}, CreateUserBody>, the compiler agrees, and at runtime the client sends a payload that’s nothing like what you declared. The types described intent; the network did whatever it wanted.
Type-safe API routes in Next.js 13 close this gap. The recipe — Zod for validation, schemas as the source of truth, types inferred everywhere downstream — is the most reliable pattern I’ve used for HTTP services in the last two years. This post wraps the TypeScript-themed February series and shows the pattern in both the new app router and the legacy pages/api/ style.
The core principle
The schema is the source of truth. Types are derived from it. Validation happens at the boundary.
In code:
// lib/schemas/user.ts
import { z } from "zod";
export const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
});
export type CreateUserBody = z.infer<typeof CreateUserSchema>;
CreateUserBody is a type. CreateUserSchema is a runtime validator. They can’t drift because the type is derived from the schema.
You expose CreateUserBody to callers (server code, client code, tests). You use CreateUserSchema.parse(input) at the request boundary. Inside the handler, the parsed result has type CreateUserBody. The handler body never validates again — it trusts the contract.
App router: route.ts handlers
In Next.js 13’s app router, an HTTP endpoint is a file named route.ts inside the app/ tree. It exports functions named after HTTP methods.
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { CreateUserSchema } from "@/lib/schemas/user";
import { db } from "@/lib/db";
export async function POST(req: NextRequest) {
const raw = await req.json().catch(() => null);
const parsed = CreateUserSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{ error: "invalid_body", issues: parsed.error.issues },
{ status: 400 }
);
}
const user = await db.user.create({ data: parsed.data });
return NextResponse.json(user, { status: 201 });
}
export async function GET() {
const users = await db.user.findMany();
return NextResponse.json(users);
}
Inside the POST block after the validation guard, parsed.data is CreateUserBody. The DB layer accepts it directly. No casting, no any, no manual checks.
A thin wrapper for repeated patterns
Writing the parse-or-respond dance in every route gets old. Wrap it.
// lib/api/with-body.ts
import { NextRequest, NextResponse } from "next/server";
import { ZodSchema, z } from "zod";
type Handler<T> = (
body: T,
req: NextRequest
) => Promise<Response> | Response;
export function withBody<S extends ZodSchema>(
schema: S,
handler: Handler<z.infer<S>>
) {
return async (req: NextRequest) => {
const raw = await req.json().catch(() => null);
const parsed = schema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json(
{ error: "invalid_body", issues: parsed.error.issues },
{ status: 400 }
);
}
return handler(parsed.data, req);
};
}
Usage:
// app/api/users/route.ts
import { withBody } from "@/lib/api/with-body";
import { CreateUserSchema } from "@/lib/schemas/user";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export const POST = withBody(CreateUserSchema, async (body) => {
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
});
Three lines of business logic. Validation, error shape, and type inference handled by the wrapper.
Query params and path params
route.ts handlers receive params as the second argument:
// app/api/users/[id]/route.ts
import { z } from "zod";
import { NextResponse } from "next/server";
const ParamsSchema = z.object({ id: z.string().uuid() });
export async function GET(
_: Request,
{ params }: { params: { id: string } }
) {
const parsed = ParamsSchema.safeParse(params);
if (!parsed.success) {
return NextResponse.json({ error: "invalid_id" }, { status: 400 });
}
// parsed.data.id is uuid-validated
const user = await db.user.findUnique({ where: { id: parsed.data.id } });
if (!user) return NextResponse.json({ error: "not_found" }, { status: 404 });
return NextResponse.json(user);
}
Query strings come through req.nextUrl.searchParams:
const QuerySchema = z.object({
limit: z.coerce.number().int().positive().max(100).default(20),
offset: z.coerce.number().int().nonnegative().default(0),
});
export async function GET(req: NextRequest) {
const parsed = QuerySchema.safeParse(
Object.fromEntries(req.nextUrl.searchParams)
);
if (!parsed.success) {
return NextResponse.json({ error: "invalid_query" }, { status: 400 });
}
// parsed.data: { limit: number; offset: number }
}
z.coerce.number() converts strings (“20”) to numbers — important because query params are always strings.
Sharing types with the client
The reason this pattern compounds: the same schemas power your client-side request building.
// app/users/new/form.tsx
"use client";
import { CreateUserSchema, type CreateUserBody } from "@/lib/schemas/user";
async function submit(body: CreateUserBody) {
// Optional: validate client-side too, before round-tripping
CreateUserSchema.parse(body);
const res = await fetch("/api/users", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`status ${res.status}`);
return res.json();
}
The client uses the same type. Same validation if you want to fail fast in the form. Refactoring the schema breaks the client at compile time — which is the point.
Response type safety
The request side is solved. The response side is where most teams give up: res.json() returns any.
You can fix it with a small helper:
// lib/api/typed-fetch.ts
import { ZodSchema, z } from "zod";
export async function typedFetch<S extends ZodSchema>(
url: string,
schema: S,
init?: RequestInit
): Promise<z.infer<S>> {
const res = await fetch(url, init);
if (!res.ok) throw new Error(`status ${res.status}`);
const raw = await res.json();
return schema.parse(raw);
}
// usage
const UserSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string(),
});
const user = await typedFetch("/api/users/u_1", UserSchema);
// user: { id: string; email: string; name: string }
You validate the response shape at the boundary, same as input. Now the type promise is backed by a runtime check.
tRPC and other higher-level options
This whole pattern — schema-validated, type-inferred, end-to-end — is what tRPC packages into a framework. If you’re starting fresh and don’t have an existing REST API to maintain, tRPC gives you all of this with less ceremony, plus a transport that doesn’t need URL design.
The pieces:
- tRPC works inside
pages/api/and has growing support for app router in Feb 2023 (still rough; expect smoother integration through Q2). - It uses Zod (or other validators) for input schemas.
- The client gets fully-inferred types from imported router types — no manual schema mirroring.
If your project is part-REST, part-internal-RPC, mix them. Use REST handlers for endpoints external clients call; use tRPC for the parts only your own UI hits.
Common Pitfalls
- Skipping runtime validation because the types say it’s fine. Types are a hope. The network is a fact.
- Validating with
parse()and letting it throw. That’s fine for internal code; in HTTP handlers, usesafeParse()and return a structured error. Throwing zod errors as 500s is bad. - Exposing zod issues directly to untrusted clients. Zod’s
issuesarray can leak internal field names. Pick what to expose; don’t dump the raw array to public clients. - Using JSON.parse on
req.text()and missing the error handler. Always wrap parsing in.catch(() => null)or a try-block. A non-JSON body should be a 400, not a 500. - Forgetting
z.coercefor query params. Query strings are always strings. Without coercion you’ll getz.stringerrors on numbers. - Mixing app router and
pages/api/handlers for the same resource. Pick one. Two endpoint trees for the same resource is a confusion you’ll regret in three months. - Inferring types from response runtime data without schema validation.
await res.json() as Whateveris back to the old lie. UsetypedFetchor its equivalent.
Wrapping Up
The schema is the source of truth. Types are derived. Validation lives at the boundary. That’s the entire pattern. It’s worth slowing down to build the small wrappers — withBody, typedFetch — early in a project because they pay back tenfold across every endpoint you add.
This wraps the February series on TypeScript and Next.js 13. Next month moves into Go territory: Building scalable Go microservices — the discipline of types and contracts looks different in Go, but the underlying instinct is the same. External reference for the curious: Zod’s documentation.
Refactor your highest-traffic endpoint into this shape this week. The tax is a day. The runway it buys is months.