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 optional revalidate.
  • With Cache Components, GET follows 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 the GET export 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.