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.