Overview
A route handler is a route.ts file inside app/ that exports one function per HTTP method, using the Web Request and Response APIs (extended by NextRequest and NextResponse). It replaces pages/api/ and exists for true HTTP endpoints. For mutations triggered from a React component, prefer nextjs-server-actions.
Export named methods from route.ts
Export GET, POST, PUT, PATCH, DELETE, HEAD, or OPTIONS; the folder path is the URL. An unexported method returns 405. A route.ts cannot share a segment with a page.tsx, because each takes over all HTTP verbs for its path.
// app/api/posts/route.ts
export async function GET() {
return Response.json(await db.post.findMany());
}
export async function POST(req: Request) {
const post = await db.post.create({ data: await req.json() });
return Response.json(post, { status: 201 });
}Await params through RouteContext
params is a Promise in the second argument. Type it with the generated RouteContext helper (types are generated by next dev, next build, or next typegen).
// app/api/posts/[id]/route.ts
export async function GET(_req: NextRequest, ctx: RouteContext<"/api/posts/[id]">) {
const { id } = await ctx.params;
const post = await db.post.findUnique({ where: { id } });
return post ? Response.json(post) : new Response("Not Found", { status: 404 });
}Validate input and return precise status codes
A handler is a public endpoint, so validate every input with a schema (Zod) and return the right code: 400 malformed, 401 unauthenticated, 403 forbidden, 404 missing, 409 conflict, 422 failed validation. Set Location on 201 responses. Handlers control status codes, headers (Cache-Control, Content-Disposition), and non-JSON bodies, which Server Actions cannot.
const parsed = Input.safeParse(await req.json());
if (!parsed.success) return Response.json({ errors: z.flattenError(parsed.error) }, { status: 422 });Verify webhooks against the raw body
Read the unparsed body with await req.text() and verify the provider’s signature before parsing JSON, because re-serialized JSON no longer matches the signed bytes. Webhooks, cron calls, mobile apps, and other non-React clients are what handlers are for; see webhooks.
Stream responses with ReadableStream
Return a streaming Response for AI completions, large exports, or server-sent events. For SSE, set Content-Type: text/event-stream and Cache-Control: no-cache, and write chunks as data: ...\n\n.
return new Response(stream, {
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
});Cache GETs explicitly
Handlers are not cached by default, and only GET can be cached; other methods are never cached.
- Without Cache Components, opt in with
export const dynamic = "force-static"and optionalrevalidate. - With Cache Components,
GETfollows the page model: it prerenders when it reads no uncached or runtime data, and you cache data with"use cache"inside a helper function. The directive cannot go on theGETexport itself.
export async function GET() {
return Response.json(await getProducts());
}
async function getProducts() {
"use cache";
cacheLife("hours");
return db.product.findMany();
}With Cache Components, reading request.url, headers, cookies, a database, or the network in a GET ends prerendering, and the GET runs at request time. Special files such as sitemap.ts and opengraph-image.tsx stay static unless they read request data. See nextjs-caching.
Use the Node.js runtime
The Edge runtime is deprecated in Next.js 16, and export const runtime = "edge" is unsupported under Cache Components. Remove the runtime export; Node.js is the default, so Prisma, pg, the filesystem, and image or PDF processing all work.
Pick a handler over an action when
- A non-React client calls the endpoint (mobile app, webhook, cron, CLI).
- The response needs custom headers, status codes, or a non-JSON body.
- The client streams the response or the URL is a stable public API contract.
For a mutation a React form triggers, use a server action.