Route handlers

Write an HTTP endpoint with the web Request and Response objects, and know when you need one at all.

09of 16
≈ 11Min read
7Parts

route.ts, and the methods it exports

One file per path, one exported function per method — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Anything you do not export answers 405 by itself.

app/api/doors/route.js
export async function GET(request) {
  const { searchParams } = new URL(request.url);
  const q = searchParams.get("q") ?? "";

  const doors = await db.doors.search(q);

  return Response.json(doors);
}

export async function POST(request) {
  const body = await request.json();
  const door = await db.doors.insert(body);

  return Response.json(door, { status: 201, headers: { Location: `/api/doors/${door.id}` } });
}

Request and Response are the web ones

the same API as in a browser, and in workers
request.method
request.url                       // a full absolute URL string
new URL(request.url).searchParams
request.headers.get("authorization")
await request.json()
await request.formData()
await request.text()

Response.json(value, { status: 201 })
new Response("Not found", { status: 404 })
new Response(null, { status: 204 })
new Response(stream, { headers: { "Content-Type": "text/event-stream" } })
Nothing new to learn

These are the objects fetch already gave you, used from the other side. The same handler shape runs on Node, on Deno, on Bun and on edge runtimes — which is why the web standard was chosen.

Dynamic segments and cookies

app/api/doors/[id]/route.js
export async function GET(request, { params }) {
  const { id } = await params;              // Next 15: params is a Promise
  const door = await db.doors.byId(id);

  if (!door) return Response.json({ error: "Not found" }, { status: 404 });

  return Response.json(door);
}

import { cookies, headers } from "next/headers";
const session = (await cookies()).get("sid")?.value;

Caching applies here too

A handler that returns the same JSON for everyone can be prerendered, exactly like a page. One that reads a session cannot, and Next works that out from what you touched.

a GET handler may be static
export const dynamic = "force-dynamic";   // never cached
export const revalidate = 3600;           // regenerate hourly

// reading cookies, headers or searchParams makes it dynamic anyway

When you do not need one

Write a route handler when something outside your React tree needs it: a mobile app, a webhook, a cron job, a public API, an OG image, a file download, a redirect from an old URL.

  • Reading data for your own page — do it in the server component. An endpoint your own app calls with fetch is a round trip you added for nothing.
  • A form that writes — a server action is less code and works without JavaScript.
  • Proxying a third-party API to hide a key — that is what the server component already is.

The ones that are genuinely useful

four real examples
// a webhook — verify the signature before trusting a byte
export async function POST(request) {
  const raw = await request.text();
  if (!verify(raw, request.headers.get("stripe-signature"))) {
    return new Response("Bad signature", { status: 400 });
  }
  …
  return new Response(null, { status: 204 });
}

// a health check for the platform
export async function GET() { return Response.json({ ok: true }); }

// a file the browser should download
return new Response(csv, {
  headers: { "Content-Type": "text/csv", "Content-Disposition": 'attachment; filename="doors.csv"' },
});

// a redirect
return Response.redirect(new URL("/doors", request.url), 308);

Treat it like any public endpoint

  • Authenticate and authorise; a handler is reachable by anyone who knows the path.
  • Validate the body with a schema before it touches anything.
  • Answer with the right status: 400 for malformed, 401/403 for who you are, 404 for missing, 422 for invalid values.
  • Rate-limit anything expensive, and never echo an internal error message back.

Practice