
Route Handlers in Next.js: Building REST Endpoints in the App Router
Server Components and Server Actions cover most of what a Next.js app needs from the server. Your pages read data directly, and your forms call functions that run on the server. But sooner or later you need a real HTTP endpoint: a mobile app needs JSON, Stripe needs somewhere to send webhooks, a partner needs a public API, or a Client Component needs to poll for updates. In the App Router, that's what Route Handlers are for.
Route Handlers are built on the standard Web Request and Response APIs, so if you've used fetch, most of this will feel familiar. In this post you'll build a small but complete REST resource with list, create, read, update, and delete endpoints. Along the way I'll cover dynamic segments, query strings, request bodies, validation, status codes, headers and cookies, CORS, caching, and non-JSON responses, plus the cases where you shouldn't reach for a Route Handler at all.
The Basics
A Route Handler is a file named route.ts (or route.js) inside the app directory. You export a function named after each HTTP method you want to support:
// app/api/health/route.ts
export async function GET() {
return Response.json({ status: "ok", time: new Date().toISOString() });
}
Visit /api/health and you get JSON back. A few rules to know up front:
- The supported methods are
GET,POST,PUT,PATCH,DELETE,HEAD, andOPTIONS. Calling a method you didn't export returns405 Method Not Allowed. - If you don't export
OPTIONS, Next.js adds one automatically and sets theAllowheader based on the methods you did export. - A
route.tscan't live in the same folder as apage.tsx. Each URL is either a page or an endpoint. That's why handlers usually live underapp/api/, though nothing requires theapiprefix. - Route Handlers don't participate in layouts. They're the lowest-level routing primitive: a request comes in, a response goes out.
Response.json() is the standard Web API. Next.js also provides NextResponse from next/server, which extends Response with helpers for cookies, redirects, and rewrites. For plain JSON, either works.
Building a REST Resource
Let's build a /api/tasks resource. The folder structure maps directly to URLs:
app/
└── api/
└── tasks/
├── route.ts # GET /api/tasks, POST /api/tasks
└── [id]/
└── route.ts # GET, PATCH, DELETE /api/tasks/:id
A Data Layer
To keep the example self-contained, tasks live in an in-memory Map. In a real app this module is where your Prisma, Drizzle, or SQL calls go; the handlers won't change.
// lib/tasks.ts
export type Task = {
id: string;
title: string;
done: boolean;
createdAt: string;
};
// In-memory store for the example. Resets on restart and isn't shared
// between serverless instances: replace with a real database.
const tasks = new Map<string, Task>();
export async function listTasks(options: { done?: boolean; limit: number }) {
return [...tasks.values()]
.filter((t) => options.done === undefined || t.done === options.done)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
.slice(0, options.limit);
}
export async function getTask(id: string) {
return tasks.get(id) ?? null;
}
export async function createTask(title: string) {
const task: Task = {
id: crypto.randomUUID(),
title,
done: false,
createdAt: new Date().toISOString(),
};
tasks.set(task.id, task);
return task;
}
export async function updateTask(
id: string,
changes: Partial<Pick<Task, "title" | "done">>,
) {
const existing = tasks.get(id);
if (!existing) return null;
const updated = { ...existing, ...changes };
tasks.set(id, updated);
return updated;
}
export async function deleteTask(id: string) {
return tasks.delete(id);
}
Validation Schemas
Request bodies and query strings are untrusted input. I'll use Zod to validate them, which also gives you typed data afterwards:
npm install zod
// lib/task-schemas.ts
import { z } from "zod";
export const createTaskSchema = z.object({
title: z.string().trim().min(1).max(200),
});
export const updateTaskSchema = z
.object({
title: z.string().trim().min(1).max(200).optional(),
done: z.boolean().optional(),
})
.refine((data) => Object.keys(data).length > 0, {
message: "Provide at least one field to update",
});
export const listQuerySchema = z.object({
done: z.enum(["true", "false"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
z.coerce.number() is handy for query strings, where everything arrives as a string. The refine on the update schema rejects an empty PATCH body, which would otherwise succeed and do nothing.
Consistent Errors
Clients of your API will thank you for a predictable error shape. A tiny helper keeps every handler consistent:
// lib/api-response.ts
import type { ZodError } from "zod";
export function errorResponse(
status: number,
message: string,
details?: unknown,
) {
return Response.json({ error: { message, details } }, { status });
}
export function validationError(error: ZodError) {
return errorResponse(
422,
"Validation failed",
error.issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
})),
);
}
export async function readJson(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
return undefined;
}
}
readJson matters more than it looks. request.json() throws on an empty or malformed body, and an uncaught throw turns into a generic 500. Returning undefined lets the schema produce a proper 422 instead.
The Collection Endpoint
// app/api/tasks/route.ts
import type { NextRequest } from "next/server";
import { createTask, listTasks } from "@/lib/tasks";
import { createTaskSchema, listQuerySchema } from "@/lib/task-schemas";
import { readJson, validationError } from "@/lib/api-response";
export async function GET(request: NextRequest) {
const query = listQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!query.success) return validationError(query.error);
const { done, limit } = query.data;
const tasks = await listTasks({
done: done === undefined ? undefined : done === "true",
limit,
});
return Response.json({ data: tasks });
}
export async function POST(request: Request) {
const body = createTaskSchema.safeParse(await readJson(request));
if (!body.success) return validationError(body.error);
const task = await createTask(body.data.title);
return Response.json(
{ data: task },
{
status: 201,
headers: { Location: `/api/tasks/${task.id}` },
},
);
}
GET reads query parameters from request.nextUrl.searchParams, a standard URLSearchParams that NextRequest parses for you. Object.fromEntries turns it into a plain object for Zod. /api/tasks?done=false&limit=5 returns up to five open tasks.
POST returns 201 Created with a Location header pointing at the new resource. That's the conventional REST response, and some HTTP clients use the header directly.
The Item Endpoint
Dynamic segments work exactly like they do for pages. The folder [id] makes id available on the second argument to each handler:
// app/api/tasks/[id]/route.ts
import { deleteTask, getTask, updateTask } from "@/lib/tasks";
import { updateTaskSchema } from "@/lib/task-schemas";
import { errorResponse, readJson, validationError } from "@/lib/api-response";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, { params }: Context) {
const { id } = await params;
const task = await getTask(id);
if (!task) return errorResponse(404, "Task not found");
return Response.json({ data: task });
}
export async function PATCH(request: Request, { params }: Context) {
const { id } = await params;
const body = updateTaskSchema.safeParse(await readJson(request));
if (!body.success) return validationError(body.error);
const task = await updateTask(id, body.data);
if (!task) return errorResponse(404, "Task not found");
return Response.json({ data: task });
}
export async function DELETE(_request: Request, { params }: Context) {
const { id } = await params;
const deleted = await deleteTask(id);
if (!deleted) return errorResponse(404, "Task not found");
return new Response(null, { status: 204 });
}
Notice that params is a promise you await, the same as in pages and layouts. A successful DELETE returns 204 No Content with a null body; Response.json would send a body, which a 204 isn't supposed to have.
Instead of declaring the Context type by hand, you can use the globally available RouteContext helper, which Next.js generates from your folder structure during next dev, next build, or next typegen:
// app/api/tasks/[id]/route.ts (alternative signature)
export async function GET(
_request: Request,
ctx: RouteContext<"/api/tasks/[id]">,
) {
const { id } = await ctx.params;
return Response.json({ id });
}
The route literal gives you autocomplete and catches typos in param names.
Trying It Out
With npm run dev running:
# Create
curl -i -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Write the docs"}'
# List open tasks
curl "http://localhost:3000/api/tasks?done=false&limit=10"
# Update (use the id from the create response)
curl -X PATCH http://localhost:3000/api/tasks/<id> \
-H "Content-Type: application/json" \
-d '{"done":true}'
# Validation error: 422 with details
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" -d '{"title":""}'
# Delete: 204
curl -i -X DELETE http://localhost:3000/api/tasks/<id>
Reading Headers, Cookies, and Other Body Types
Headers and Cookies
You can read headers straight off the request, or use the async headers() and cookies() functions from next/headers:
// app/api/me/route.ts
import { cookies, headers } from "next/headers";
export async function GET() {
const headerList = await headers();
const cookieStore = await cookies();
const userAgent = headerList.get("user-agent");
const theme = cookieStore.get("theme")?.value ?? "system";
return Response.json({ userAgent, theme });
}
Both functions are async in Next.js 15 and later, so don't forget the await. Inside a Route Handler you can also set cookies:
// app/api/preferences/route.ts
import { cookies } from "next/headers";
import { z } from "zod";
const schema = z.object({ theme: z.enum(["light", "dark", "system"]) });
export async function POST(request: Request) {
const body = schema.safeParse(await request.json().catch(() => undefined));
if (!body.success) {
return Response.json({ error: "Invalid theme" }, { status: 422 });
}
const cookieStore = await cookies();
cookieStore.set("theme", body.data.theme, {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 365,
path: "/",
});
return new Response(null, { status: 204 });
}
Form Data and Raw Text
Not every client sends JSON. HTML forms send multipart/form-data or application/x-www-form-urlencoded, both readable with request.formData(). Webhook providers usually need the raw body for signature checks, which is what request.text() is for.
// app/api/contact/route.ts
export async function POST(request: Request) {
const form = await request.formData();
const email = form.get("email");
const message = form.get("message");
if (typeof email !== "string" || typeof message !== "string") {
return Response.json({ error: "Missing fields" }, { status: 400 });
}
// send the message...
return Response.json({ ok: true });
}
A request body can only be read once. If you need it twice (for example, to verify a signature on the raw text and then parse JSON), read it as text and call JSON.parse yourself, or request.clone() before the first read.
Authentication
Route Handlers are public URLs. Anyone can call them with any payload, so check authorization inside every handler that needs it, not just in your UI.
// lib/require-api-key.ts
import { timingSafeEqual } from "node:crypto";
export function hasValidApiKey(request: Request) {
const header = request.headers.get("authorization") ?? "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
const expected = process.env.API_KEY ?? "";
const a = Buffer.from(token);
const b = Buffer.from(expected);
return expected.length > 0 && a.length === b.length && timingSafeEqual(a, b);
}
// app/api/admin/stats/route.ts
import { hasValidApiKey } from "@/lib/require-api-key";
export async function GET(request: Request) {
if (!hasValidApiKey(request)) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
return Response.json({ users: 1280, activeToday: 214 });
}
timingSafeEqual compares the tokens in constant time, so an attacker can't guess the key character by character from response times. For cookie-based sessions you'd read the session with your auth library instead; see managing authentication in Next.js. You can also run coarse checks for many routes at once in proxy.ts (the Next.js 16 name for middleware), but treat that as a first filter, not a replacement for checking inside the handler.
CORS
By default, browsers block JavaScript on other origins from reading your responses. If a separate frontend needs to call your API, return CORS headers, and answer the preflight OPTIONS request yourself so you control what's allowed:
// app/api/public/products/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "https://shop.example.com",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
const products = [{ id: 1, name: "Mug", price: 12 }];
return Response.json({ data: products }, { headers: corsHeaders });
}
Avoid Access-Control-Allow-Origin: * on endpoints that use cookies or return private data. If you need the same CORS policy on many routes, set it once with the headers option in next.config.ts or in proxy.ts.
Caching GET Handlers
Route Handlers are not cached by default: every request runs your function. Only GET handlers can opt into caching; POST, PATCH, and the rest always run.
How you opt in depends on whether your app has Cache Components enabled.
Without Cache Components, use route segment config. revalidate caches the response and refreshes it in the background at most once per interval:
// app/api/exchange-rates/route.ts
export const revalidate = 3600; // seconds
export async function GET() {
const res = await fetch("https://api.example.com/rates");
const rates = await res.json();
return Response.json(rates);
}
With Cache Components (cacheComponents: true in next.config.ts), segment options like revalidate and dynamic don't apply. Instead, GET handlers behave like pages: a handler that touches no uncached or request data is prerendered, and you cache data with "use cache" in a helper function (it can't go directly in the handler body):
// app/api/exchange-rates/route.ts
import { cacheLife } from "next/cache";
async function getRates() {
"use cache";
cacheLife("hours");
const res = await fetch("https://api.example.com/rates");
return res.json();
}
export async function GET() {
return Response.json(await getRates());
}
Either way, as soon as a handler reads the request (request.url, headers, cookies, the body) it runs per request, which is what you want for anything personalized.
Non-JSON Responses
A Route Handler can return anything Response can carry. A CSV export is a few lines:
// app/api/tasks/export/route.ts
import { listTasks } from "@/lib/tasks";
function csvCell(value: string) {
return `"${value.replaceAll('"', '""')}"`;
}
export async function GET() {
const tasks = await listTasks({ limit: 1000 });
const rows = [
"id,title,done,createdAt",
...tasks.map((t) =>
[t.id, csvCell(t.title), String(t.done), t.createdAt].join(","),
),
];
return new Response(rows.join("\n"), {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="tasks.csv"',
},
});
}
Content-Disposition: attachment makes the browser download the file rather than display it. Because app/api/tasks/export/route.ts is a static segment, it takes precedence over the dynamic [id] folder next to it, so /api/tasks/export doesn't get treated as a task ID.
For sitemap.xml, robots.txt, and Open Graph images, prefer the dedicated metadata file conventions, which are built on the same machinery but handle the details for you. For long-running output like AI responses or live updates, a Route Handler can also return a stream; that's covered in streaming responses and server-sent events.
Webhooks
Webhooks are the textbook Route Handler use case: a third party sends a POST, you verify it, process it, and return quickly.
// app/api/webhooks/orders/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";
function isValidSignature(payload: string, signature: string | null) {
if (!signature || !process.env.WEBHOOK_SECRET) return false;
const expected = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(payload)
.digest("hex");
const a = Buffer.from(signature);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(request: Request) {
const payload = await request.text();
const signature = request.headers.get("x-signature");
if (!isValidSignature(payload, signature)) {
return new Response("Invalid signature", { status: 401 });
}
const event = JSON.parse(payload) as { type: string; id: string };
// Record event.id to ignore retries you've already processed,
// then handle the event.
console.log("Received", event.type, event.id);
return new Response(null, { status: 204 });
}
The signature is computed over the exact bytes you received, which is why the body is read with text() and only parsed after verification. Providers retry on failures and timeouts, so store event IDs and make processing idempotent. The header name and signing scheme vary by provider; follow their docs (Stripe, for example, ships a helper in its SDK).
When Not to Use a Route Handler
Route Handlers are flexible enough that it's tempting to route everything through them. Two cases where you shouldn't:
Fetching data for Server Components. Don't fetch("/api/tasks") from a Server Component. It adds an HTTP round trip to your own server, and during next build there's no server running, so prerendered pages will fail. Call listTasks() directly instead. The Route Handler exists for clients that can't import your code.
Form submissions and mutations from your own UI. A Server Action is less code: no URL, no JSON parsing, typed arguments, and built-in integration with revalidation. See Server Actions for form submissions.
A quick decision table:
| Need | Use |
|---|---|
| Read data to render a page | Server Component, call the data layer directly |
| Mutate data from your own forms and buttons | Server Action |
| JSON API for a mobile app or third party | Route Handler |
| Webhooks and OAuth callbacks | Route Handler |
| Client-side polling or infinite loading | Route Handler (GET) |
| File downloads, feeds, custom content types | Route Handler |
Deployment Notes
On serverless platforms each handler may run in a fresh, short-lived instance. That has practical consequences:
- Don't keep state in module variables (like the in-memory
Mapabove) in production. It won't be shared between instances or survive cold starts. - Long-running work may hit the platform's timeout. You can raise it per route with
export const maxDuration = 30where your host supports it. - WebSockets aren't supported by Route Handlers. Use server-sent events or a dedicated real-time service.
- Handlers run on the Node.js runtime by default. The Edge runtime is deprecated in Next.js 16, so don't add
export const runtime = "edge"to new code.
Conclusion
Route Handlers turn any folder under app into an HTTP endpoint using standard Request and Response objects. Export a function per method, await your params, validate every input, return honest status codes (201, 204, 404, 422), and keep a consistent error shape. Add explicit auth checks and CORS where needed, opt GET handlers into caching only when the data allows it, and use them for the clients that actually need HTTP: mobile apps, third parties, webhooks, and client-side fetching. For everything your own pages and forms do, Server Components and Server Actions remain the simpler path.


