Overview
A route handler is a route.ts (or route.js) file inside app/ that exports one function per HTTP method. The framework wires it as a Web-standard Request/Response handler. Route handlers replace pages/api/ and they exist for one job: true HTTP endpoints. For mutations triggered from a React component, prefer nextjs-server-actions.
Export named methods from route.ts
A handler module exports GET, POST, PUT, PATCH, DELETE, HEAD, or OPTIONS. The file path determines the URL: app/api/posts/route.ts serves /api/posts.
// app/api/posts/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const posts = await db.post.findMany();
return NextResponse.json(posts);
}
export async function POST(req: Request) {
const body = await req.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}A method not exported returns 405 Method Not Allowed. Do not mix route.ts and page.tsx in the same folder; the framework rejects it.
Read dynamic params from the second argument
For app/api/posts/[id]/route.ts, the framework passes params as the second argument. In Next.js 15, params arrive as a Promise; await them.
// app/api/posts/[id]/route.ts
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
if (!post) return new Response("Not Found", { status: 404 });
return Response.json(post);
}Catch-all and optional catch-all segments ([...slug], [[...slug]]) follow the same convention; the param type widens to string[].
Validate inputs and return real status codes
A handler is a public endpoint; validate everything. Return 400 for malformed input, 401 for missing auth, 403 for forbidden, 404 for missing, 409 for conflict, 422 for semantic validation failure.
import { z } from "zod";
const Input = z.object({ title: z.string().min(1).max(200) });
export async function POST(req: Request) {
const parsed = Input.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ errors: parsed.error.flatten() }, { status: 422 });
}
const post = await db.post.create({ data: parsed.data });
return Response.json(post, { status: 201, headers: { Location: `/api/posts/${post.id}` } });
}A handler is the right place to set custom headers (Location, Cache-Control, Content-Disposition). Server Actions cannot do this; if the response shape needs control, use a handler.
Stream responses with ReadableStream
A handler can return a streaming Response. Use it for AI completions, large CSV exports, or server-sent events.
export async function GET() {
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of model.stream(prompt)) {
controller.enqueue(new TextEncoder().encode(chunk));
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});
}For server-sent events, set Content-Type: text/event-stream and format chunks as data: ...\n\n. The client uses EventSource to consume them.
Cache GETs explicitly; mutations are dynamic
In Next.js 15, GET handlers are dynamic by default. Opt in to static caching with export const dynamic = "force-static" for handlers whose response never changes per request.
export const dynamic = "force-static";
export const revalidate = 3600;
export async function GET() {
const config = await loadPublicConfig();
return Response.json(config);
}For per-request caching, set Cache-Control headers on the response. Mutation methods (POST, PUT, PATCH, DELETE) are always dynamic; the runtime does not cache them. See nextjs-caching for the larger cache model.
Reach for handlers when actions cannot
A handler is the right tool when:
- A non-React client calls the endpoint (mobile app, third-party webhook, cron, CLI).
- The response needs custom headers, status codes, or a non-JSON body.
- The client streams the response (SSE, chunked downloads).
- The path participates in a public API contract with stable URLs and methods.
For a mutation a React form triggers, prefer a server action: better type safety, integrated cache invalidation, progressive enhancement, no manual JSON parsing. See nextjs-server-actions.
Pick the runtime per handler
A handler can run on the Edge runtime for low-latency global execution, or on the Node runtime for native modules and longer compute. Set it per file:
export const runtime = "edge"; // or "nodejs"Edge handlers cannot use pg, native node:* modules, or the filesystem. Use them for auth checks, redirects, geolocation, A/B routing, and small JSON responses. Use Node for Prisma, image processing, PDF generation, and anything Node-only. The trade-offs match the runtime guidance in vercel.