Typing Route Handlers
Recipe
Type Next.js App Router route handlers with NextRequest, NextResponse, and typed route parameters. Build type-safe API endpoints with proper request/response contracts.
Working Example
// app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
type User = {
id: string;
name: string;
email: string;
};
type CreateUserBody = {
name: string;
email: string;
};
type ApiErrorResponse = {
error: string;
};
// GET handler
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const role = searchParams.get("role");
const users: User[] = await fetchUsersFromDb(role);
return NextResponse.json(users);
}
// POST handler with typed body
export async function POST(request: NextRequest) {
const body: unknown = await request.json();
if (!isValidCreateUserBody(body)) {
return NextResponse.json(
{ error: "Invalid request body" } satisfies ApiErrorResponse,
{ status: 400 }
);
}
const newUser: User = {
id: crypto.randomUUID(),
name: body.name,
email: body.email,
};
// ... save to database
return NextResponse.json(newUser, { status: 201 });
}
// Type guard for request body
function isValidCreateUserBody(body: unknown): body is CreateUserBody {
return (
typeof body === "object" &&
body !== null &&
"name" in body &&
"email" in body &&
typeof (body as CreateUserBody).name === "string" &&
typeof (body as CreateUserBody).email === "string"
);
}// app/api/users/[id]/route.ts - Dynamic route params
import { NextRequest, NextResponse } from "next/server";
type RouteContext = {
params: Promise<{ id: string }>;
};
export async function GET(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
const user = await findUserById(id);
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json(user);
}
export async function DELETE(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
await deleteUserById(id);
return new NextResponse(null, { status: 204 });
}Deep Dive
How It Works
- Route handlers export named functions matching HTTP methods:
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS. NextRequestextends the WebRequestAPI with convenience methods likenextUrl.searchParamsfor query parameter access.NextResponse.json(data, init?)creates a JSON response with properContent-Typeheaders. Thedataparameter accepts any serializable value.- In Next.js 15, route
paramsare wrapped in aPromiseand must be awaited. This aligns with the async params pattern used in page components. - Route handlers are server-only code. They can safely access databases, environment variables, and server-side secrets.
Variations
Zod validation in route handlers:
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = CreateUserSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", details: parsed.error.flatten() },
{ status: 400 }
);
}
const { name, email } = parsed.data; // Fully typed
// ... create user
}Setting headers and cookies:
export async function GET(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const response = NextResponse.json({ data: "protected" });
response.headers.set("X-Custom-Header", "value");
return response;
}Streaming response:
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (const chunk of ["Hello", " ", "World"]) {
controller.enqueue(encoder.encode(chunk));
await new Promise((r) => setTimeout(r, 100));
}
controller.close();
},
});
return new NextResponse(stream, {
headers: { "Content-Type": "text/plain" },
});
}TypeScript Notes
request.json()returnsPromise<any>. Always validate or cast the result before using it.NextResponse.json()accepts any value. Usesatisfiesto check the shape:NextResponse.json({ error: "msg" } satisfies ApiErrorResponse).- Route params are always strings. Even numeric-looking params like
/api/users/123haveidasstring. Parse them explicitly:parseInt(id, 10).
Gotchas
- In Next.js 15,
paramsis aPromise. Forgetting toawaitit gives you aPromiseobject instead of the params. This is a common migration issue from Next.js 14. request.json()throws if the body is not valid JSON. Wrap it in a try/catch for robustness.- Route handlers do not have access to the React component tree. They cannot use hooks or context.
- Returning
new Response()instead ofNextResponseworks but loses Next.js-specific features likecookies()andheaders()helpers.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Route Handlers (App Router) | Web standard APIs, streaming support | Manual validation needed |
| Server Actions | Tighter integration with React forms | Not for third-party API consumers |
| API Routes (Pages Router) | Familiar req/res pattern | Legacy, NextApiRequest is Node-specific |
| tRPC | End-to-end type safety | Additional dependency |
| External API (Express, Fastify) | Full control, independent deployment | Separate service to maintain |
FAQs
What HTTP method exports does a route handler file support?
GET,POST,PUT,PATCH,DELETE,HEAD,OPTIONS- Export each as a named async function in the
route.tsfile.
How do I access query parameters in a route handler?
export async function GET(request: NextRequest) {
const role = request.nextUrl.searchParams.get("role");
// role is string | null
}Why is request.json() typed as Promise<any> and what should I do about it?
- The Web API
Request.json()has no way to know your payload shape at compile time. - Always validate or narrow the result before using it (type guard, Zod, or explicit cast after checks).
How do I type dynamic route params in Next.js 15?
type RouteContext = {
params: Promise<{ id: string }>;
};
export async function GET(request: NextRequest, context: RouteContext) {
const { id } = await context.params;
}- Params are wrapped in a
Promiseand must be awaited.
Gotcha: What happens if I forget to await params in Next.js 15?
- You get a
Promiseobject instead of the actual params. - This is a common migration issue from Next.js 14 where params were synchronous.
What is the satisfies keyword used for in route handlers?
- It validates that a value matches a type without widening it:
return NextResponse.json({ error: "msg" } satisfies ApiErrorResponse);- The shape is checked at compile time, but the runtime value is unchanged.
How do I validate request bodies with Zod in a route handler?
- Parse the body with
safeParseand checkparsed.success:
const parsed = CreateUserSchema.safeParse(await request.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { name, email } = parsed.data; // Fully typedAre route params always strings even if they look like numbers?
- Yes. A param like
/api/users/123givesidas"123"(string). - Parse explicitly with
parseInt(id, 10)orNumber(id)if you need a number.
Gotcha: What happens if the request body is not valid JSON?
request.json()throws an error at runtime.- Always wrap it in a try/catch for robustness in production route handlers.
Can route handlers use React hooks or access the component tree?
- No. Route handlers are server-only code that runs outside the React component tree.
- They cannot use hooks, context, or any client-side React APIs.
What is the difference between returning Response and NextResponse?
new Response()works but lacks Next.js-specific helpers likecookies()andheaders().NextResponseextendsResponsewith convenience methods. Prefer it for Next.js features.
How do I write a type guard for a request body?
function isValidBody(body: unknown): body is CreateUserBody {
return (
typeof body === "object" &&
body !== null &&
"name" in body &&
typeof (body as CreateUserBody).name === "string"
);
}