#Route Handlers
AddedA Route Handler is a route.ts file inside app/ that answers HTTP requests directly. The folder path is the URL, as it is for pages. Each function the file exports is named after an HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), and it speaks the web-standard Fetch API: a Request comes in, you return a Response. There's no React, no layout, no HTML unless you write it yourself. It's what you use for a JSON API that a mobile app or a third party calls, a webhook receiver, a file download, an RSS feed, or an OAuth callback.
The mental model: the app/ folder is a building and every URL is a door. Behind a page.tsx door is a furnished room: layouts, React, HTML, client-side navigation. A route.ts door is a service hatch: no decoration, you pass a request through and get exactly the bytes you hand back. That's why one folder can't have both. The URL /hello can lead to a room or a hatch, not both, and Next.js refuses to build if you try.
If you know Express, the shape is familiar but the API isn't Express's. There's no req, res, next: you get a Request (Next.js passes a NextRequest, a subclass with nextUrl and cookies helpers) and you return the response instead of calling res.send(). If you know the Pages Router, this replaces pages/api/* (see API Routes); the two do the same job and you don't need both.
Here is a small to-do API: a collection route first, then the in-memory store it imports (standing in for a database), then an item route with a dynamic segment.
import { type NextRequest, NextResponse } from "next/server";
import { addTodo, listTodos } from "@/lib/todos";
// GET /api/todos?done=true
export async function GET(request: NextRequest) {
const done = request.nextUrl.searchParams.get("done");
const todos = listTodos(done === null ? undefined : done === "true");
return NextResponse.json({ todos });
}
// POST /api/todos {"title": "..."}
export async function POST(request: Request) {
if (!request.headers.get("content-type")?.startsWith("application/json")) {
return NextResponse.json({ error: "Send application/json" }, { status: 415 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Malformed JSON" }, { status: 400 });
}
const title = (body as { title?: unknown } | null)?.title;
if (typeof title !== "string" || title.trim() === "") {
return NextResponse.json({ error: "title is required" }, { status: 422 });
}
const todo = addTodo(title.trim());
return NextResponse.json(todo, {
status: 201,
headers: { Location: `/api/todos/${todo.id}` },
});
}- The file lives at
app/api/todos/route.ts, so it answers/api/todos. Theapi/folder is only a convention; aroute.tscan sit anywhere inapp/as long as that folder has nopage.tsx. GETreceives aNextRequest.request.nextUrlis a parsedURL, sosearchParams.get("done")gives"true","false"ornull.nullmeans "no filter", which is why it maps toundefinedrather thanfalse:curl "localhost:4508/api/todos?done=false"returned only{"id":2,"title":"Ship the API","done":false}.NextResponse.json(data, init)isResponse.jsonwith Next.js's extras. It serialises the object and setscontent-type: application/json.POSTuses a plainRequesttype, which is enough when you only need standard methods.POSTchecks the content type first (415), then parses (request.json()throws on bad JSON, so that's a 400), then validates the shape (422). Each failure returns early with its own status. Without thetry, malformed JSON would throw out of the handler and the client would get an empty 500.- On success it returns 201 Created with a
Locationheader pointing at the new item. Status and headers go in the second argument, exactly as withnew Response(body, { status, headers }). - Nothing here is Next.js-specific except the file location and
NextResponse. That's the point of Route Handlers: they're the sameRequest/Responseobjects you'd use in a service worker, Deno, Bun or Cloudflare Workers.
The store it imports is a plain module:
export type Todo = { id: number; title: string; done: boolean };
const todos: Todo[] = [
{ id: 1, title: "Read the route.ts docs", done: true },
{ id: 2, title: "Ship the API", done: false },
];
let nextId = 3;
export function listTodos(done?: boolean) {
return done === undefined ? todos : todos.filter((t) => t.done === done);
}
export function getTodo(id: number) {
return todos.find((t) => t.id === id);
}
export function addTodo(title: string) {
const todo = { id: nextId++, title, done: false };
todos.push(todo);
return todo;
}
export function updateTodo(id: number, patch: Partial<Omit<Todo, "id">>) {
const todo = getTodo(id);
if (todo) Object.assign(todo, patch);
return todo;
}
export function deleteTodo(id: number) {
const i = todos.findIndex((t) => t.id === id);
if (i >= 0) todos.splice(i, 1);
return i >= 0;
}- It's a module-level array plus an id counter, and every exported function reads or changes that one array.
listTodos(undefined)returns everything andlistTodos(false)filters, which is why the route maps a missing?donetoundefinedrather thanfalse.updateTodousesObject.assignon the object it found, so the change is visible to every later call.deleteTodoreturnsfalsewhen nothing matched, so the item route can answer 404.- It stands in for a database. It works here because one
next startprocess holds one copy of the module; the gotcha at the end of this topic explains why that breaks on serverless.
This is what the running app returned (next build && next start -p 4508, then curl). Each command is shown before its output:
$ curl -s -i localhost:4508/api/todos
HTTP/1.1 200 OK
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type: application/json
Transfer-Encoding: chunked
{"todos":[{"id":1,"title":"Read the route.ts docs","done":true},{"id":2,"title":"Ship the API","done":false}]}
$ curl -s -i -X POST localhost:4508/api/todos -H 'content-type: application/json' -d '{"title":"Write chapter 8"}'
HTTP/1.1 201 Created
content-type: application/json
location: /api/todos/3
{"id":3,"title":"Write chapter 8","done":false}
$ curl -s -i -X POST localhost:4508/api/todos -H 'content-type: text/plain' -d 'hi'
HTTP/1.1 415 Unsupported Media Type
{"error":"Send application/json"}
$ curl -s -i -X POST localhost:4508/api/todos -H 'content-type: application/json' -d '{"title":'
HTTP/1.1 400 Bad Request
{"error":"Malformed JSON"}
$ curl -s -X POST localhost:4508/api/todos -H 'content-type: application/json' -d '{}' -w ' %{http_code}'
{"error":"title is required"} 422(Date, Connection and Keep-Alive headers are left out here and below.) Note two things in the headers. There's no Cache-Control at all on a dynamic Route Handler response; Next.js doesn't add one, so if a CDN sits in front, set it yourself. And Next.js adds vary: rsc, next-router-state-tree, … to every response, including API responses, because the same URL can serve HTML or an RSC payload for pages.
Dynamic segments and params
The item route lives in a [id] folder. Its handlers get a second argument, the context, whose params is a Promise (since Next.js 15).
import { NextResponse } from "next/server";
import { deleteTodo, getTodo, updateTodo } from "@/lib/todos";
export async function GET(_request: Request, ctx: RouteContext<"/api/todos/[id]">) {
const { id } = await ctx.params; // a Promise since Next.js 15
const todo = getTodo(Number(id));
if (!todo) return NextResponse.json({ error: `No todo ${id}` }, { status: 404 });
return NextResponse.json(todo);
}
export async function PATCH(request: Request, ctx: RouteContext<"/api/todos/[id]">) {
const { id } = await ctx.params;
const patch = (await request.json()) as { title?: string; done?: boolean };
const todo = updateTodo(Number(id), patch);
if (!todo) return NextResponse.json({ error: `No todo ${id}` }, { status: 404 });
return NextResponse.json(todo);
}
export async function DELETE(_request: Request, ctx: RouteContext<"/api/todos/[id]">) {
const { id } = await ctx.params;
if (!deleteTodo(Number(id))) {
return NextResponse.json({ error: `No todo ${id}` }, { status: 404 });
}
return new Response(null, { status: 204 });
}RouteContext<"/api/todos/[id]">is a global type thatnext build(andnext dev/next typegen) generates from your folders, soctx.paramsis typed asPromise<{ id: string }>without writing it by hand. Misspell the route string and type checking fails.await ctx.paramsgives{ id: "3" }. Params are always strings (or string arrays for[...slug]), soNumber(id)converts it./api/todos/abcgivesNumber("abc")→NaN, which matches nothing, so it's a clean 404:{"error":"No todo abc"} 404.PATCHreads the body withrequest.json()and merges it. It has notry, on purpose for this demo: sending-d 'oops'maderequest.json()throw, and the client gotHTTP/1.1 500 Internal Server Errorwith an empty body, while the server log printed⨯ SyntaxError: Unexpected token 'o', "oops" is not valid JSON. Unhandled errors never leak their message to the client, but they also give the client nothing useful, so validate input and return a 400 yourself.DELETEreturnsnew Response(null, { status: 204 }). A 204 must not have a body, so it's built with the plainResponseconstructor;NextResponse.json()always writes a body.- The item created by
POST /api/todosin the previous example was visible here:GET /api/todos/3returned{"id":3,"title":"Write chapter 8","done":false} 200. Both route files import the samelib/todos.tsmodule, and under onenext startprocess they shared one copy of it. That's true of a single long-running server, not of serverless platforms, where each instance has its own memory (see the gotcha below).
$ curl -s -X PATCH localhost:4508/api/todos/3 -H 'content-type: application/json' -d '{"done":true}' -w ' %{http_code}\n'
{"id":3,"title":"Write chapter 8","done":true} 200
$ curl -s -i -X DELETE localhost:4508/api/todos/3
HTTP/1.1 204 No Content
$ curl -s -X DELETE localhost:4508/api/todos/3 -w ' %{http_code}\n'
{"error":"No todo 3"} 404What Next.js does with methods you didn't write
You only exported some methods. The running app showed what happens to the rest:
$ curl -s -i -X PUT localhost:4508/api/todos
HTTP/1.1 405 Method Not Allowed
$ curl -s -i -X OPTIONS localhost:4508/api/todos
HTTP/1.1 204 No Content
allow: GET, HEAD, OPTIONS, POST
$ curl -s -i -X OPTIONS localhost:4508/api/todos/1
HTTP/1.1 204 No Content
allow: DELETE, GET, HEAD, OPTIONS, PATCH
$ curl -s -I localhost:4508/api/todos
HTTP/1.1 200 OK
content-type: application/jsonPUTisn't exported fromapp/api/todos/route.ts, so Next.js answers 405 Method Not Allowed without calling your code. On this dynamic route the 405 carried noAllowheader.OPTIONSisn't exported either, but Next.js implements it for you: a 204 whoseallowheader lists the methods the file exports, plusHEADandOPTIONS. That's what a CORS preflight or an API client asks for. Export your ownOPTIONSwhen the preflight needsAccess-Control-*headers.HEADisn't exported, yetcurl -Igot a 200 with thecontent-typeof theGETresponse. Next.js runs yourGETand drops the body: in a separate test app I logged from aGEThandler, and acurl -Irequest printed the log line.- So the methods you write are the only ones that run your logic, and the protocol-level ones (
HEAD,OPTIONS, 405) are handled for you.
Cookies, headers and redirects
You can read request data two ways: from the request object, or with the cookies() and headers() functions from next/headers that pages use too. To set a cookie, put it on the response.
import { cookies } from "next/headers";
import { type NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
// Two ways to read the same cookie
const fromRequest = request.cookies.get("theme")?.value;
const fromHelper = (await cookies()).get("theme")?.value;
return NextResponse.json({ fromRequest, fromHelper });
}
export async function POST(request: NextRequest) {
const { theme } = (await request.json()) as { theme: string };
const response = NextResponse.json({ saved: theme });
response.cookies.set("theme", theme, { httpOnly: true, sameSite: "lax", path: "/" });
return response;
}request.cookies.get("theme")reads the parsedCookieheader of this request;NextRequestadds thecookieshelper to the standardRequest.(await cookies()).get("theme")reads the same thing through the request-scoped API. It's async since Next.js 15, likeparams. Both returned"dark"forcurl -H 'cookie: theme=dark':{"fromRequest":"dark","fromHelper":"dark"}. With no cookie both wereundefined, whichJSON.stringifydrops, so the body was{}.POSTbuilds the response first, then callsresponse.cookies.set(…). The client receivedset-cookie: theme=dark; Path=/; HttpOnly; SameSite=lax.(await cookies()).set(…)also works in a Route Handler; the response-object form makes it obvious which response carries the cookie.- Use
request.cookieswhen you already have the request (proxy, Route Handlers). Usecookies()in code that doesn't get the request passed in: Server Components, Server Actions, helpers in your data layer.
redirect() from next/navigation works in a Route Handler too. It throws a special error that Next.js turns into a 307:
import { redirect } from "next/navigation";
export function GET() {
redirect("/api/todos");
}redirect()never returns. It throws an internalNEXT_REDIRECTerror, and Next.js catches it and sends307 Temporary Redirectwithlocation: /api/todos. Don't wrap it in atry/catchthat swallows errors, or the redirect is swallowed too.- This handler reads nothing from the request, so the build prerendered it: the route table below shows
○ /old-api. The running server answeredcurl -i localhost:4508/old-apiwith307,location: /api/todos,x-nextjs-cache: HITandcache-control: s-maxage=31536000: a stored redirect, cacheable for a year. That's fine for a permanent move; if the target depended on the request you'd read the request and the route would become dynamic. The next topic is about exactly this. - A prerendered
GETroute also answers other methods from the stored copy:curl -X POST localhost:4508/old-apigot405 Method Not AllowedwithAllow: GETandAllow: HEAD.
The build output for this app:
Route (app)
┌ ○ /_not-found
├ ƒ /api/prefs
├ ƒ /api/todos
├ ƒ /api/todos/[id]
└ ○ /old-api
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demandNextRequest and NextResponse at a glance
| You want to… | Use |
|---|---|
| Read the path or query string | request.nextUrl.pathname, request.nextUrl.searchParams |
| Read a cookie | request.cookies.get(name) or (await cookies()).get(name) |
| Read a header | request.headers.get(name) or (await headers()).get(name) |
| Read the body | await request.json(), .text(), .formData(), .arrayBuffer(): once only |
| Return JSON with a status | NextResponse.json(data, { status, headers }) or Response.json(…) |
| Return no body | new Response(null, { status: 204 }) |
| Set a cookie | response.cookies.set(name, value, options) |
| Redirect | NextResponse.redirect(new URL("/x", request.url)) or redirect("/x") |
| Stream | new Response(readableStream, { headers }) (see Streaming responses and webhooks) |
NextRequest and NextResponse are subclasses of the standard classes, so you can pass a NextRequest to anything expecting a Request and return a Response wherever a NextResponse is accepted. Before Next.js 15, NextRequest also had request.ip and request.geo; 15 removed them (your host's headers, such as x-forwarded-for, replace them).
Route Handlers and pages can't share a folder
Each URL is either a page or a route. I put both files in app/hello/ and ran next build:
$ next build
> Build error occurred
Error: Turbopack build failed with 1 error:
./.sandbox/nextjs/ch08/conflict/src/app
Error: An issue occurred while preparing your Next.js app
Conflicting route and page at /hello: route at /hello/route and page at /hello/pageThis holds even when the route.ts exports only POST (as it did here) and the page only needs GET: a route.ts takes over every method for its URL. Put the API one level down (app/hello/api/route.ts) or under app/api/. Route Handlers also don't use layouts, loading.tsx or error.tsx, and <Link> navigations don't target them: they're plain HTTP.