Checked against Next.js 16.1 on 2026-08-20.
A route handler is an HTTP endpoint you define in app/api/.../route.ts by exporting a function named after the method. That is the whole mechanism. The two things that trip people up are not in the mechanism: GET handlers stopped being cached by default in Next.js 15, and most of the endpoints people write do not need to be endpoints at all - a server action does the same job with less code.
This post covers both, plus what route handlers cost, and what changes when you run them somewhere other than Vercel. This blog is a Next.js 16 App Router app on Cloudflare Workers, so the last part is measured rather than assumed.
If you are new to the App Router, start with the Next.js App Router guide. If you are writing the client that consumes these endpoints, the React hooks guide covers the fetching side.
Route Handlers vs the Pages Router API Routes
If you used Next.js before the App Router, you wrote API routes in pages/api/. Those still work. Route handlers are the App Router equivalent, built on the Web Request and Response objects instead of Next.js-specific ones.
| Feature | Pages Router (pages/api/) | App Router (Route Handlers) |
|---|---|---|
| File location | pages/api/endpoint.ts | app/api/endpoint/route.ts |
| Request object | NextApiRequest | Standard Request (Web API) |
| Response object | NextApiResponse | Standard Response (Web API) |
| HTTP methods | One handler, req.method switch | Named exports: GET, POST, PUT, DELETE |
GET caching | Not cached | Not cached since v15, opt in with dynamic = 'force-static' |
| Route params | Plain object | Promise, must be awaited |
| Streaming | Limited | Native, via ReadableStream |
The web-standard part is the real argument for them. A handler that only touches Request and Response runs unmodified on Cloudflare Workers, Deno and Bun. A handler that reaches for NextRequest.geo does not.
Creating Your First Route Handler
The folder path becomes the URL path, and a route.ts file makes the folder an endpoint:
app/
api/
users/
route.ts -> GET /api/users, POST /api/users
[id]/
route.ts -> GET /api/users/:id, PUT, DELETE
// app/api/users/route.ts
import { NextResponse } from "next/server";
export async function GET() {
const users = await db.user.findMany();
return NextResponse.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await db.user.create({
data: { name: body.name, email: body.email },
});
return NextResponse.json(user, { status: 201 });
}typescriptEach exported function name is an HTTP method. Next.js returns 405 for methods you do not export.
Dynamic Route Parameters Are Async Now
Wrap a folder name in square brackets for a dynamic segment. Since Next.js 15 the params object is a Promise, and Next.js 16 removed synchronous access to request-time APIs entirely - params, searchParams, cookies(), headers() and draftMode() all have to be awaited.
// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function GET(request: Request, { params }: RouteParams) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
return NextResponse.json(user);
}
export async function DELETE(request: Request, { params }: RouteParams) {
const { id } = await params;
try {
await db.user.delete({ where: { id } });
return new Response(null, { status: 204 });
} catch {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
}typescriptThis is the single most common reason a tutorial from 2023 fails to compile today.
Reading Query Parameters, Headers and Cookies
// app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") || "1", 10);
const limit = parseInt(searchParams.get("limit") || "20", 10);
const search = searchParams.get("q") || "";
const authHeader = request.headers.get("authorization");
const sessionToken = request.cookies.get("session")?.value;
const offset = (page - 1) * limit;
const [products, total] = await Promise.all([
db.product.findMany({
where: search ? { name: { contains: search } } : undefined,
skip: offset,
take: limit,
}),
db.product.count({
where: search ? { name: { contains: search } } : undefined,
}),
]);
return NextResponse.json({
data: products,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
}typescriptNextRequest buys you nextUrl and cookies. Plain Request is enough for everything else, and it is what keeps the handler portable.
Input Validation With Zod
Validate every body, query parameter and dynamic segment. Zod gives you runtime validation and the static type from one declaration, which pairs with the narrowing patterns in TypeScript best practices:
// lib/validations/user.ts
import { z } from "zod";
export const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(["admin", "editor", "viewer"]).default("viewer"),
});
export type CreateUserInput = z.infer<typeof createUserSchema>;typescript// app/api/users/route.ts
export async function POST(request: Request) {
const body = await request.json();
const result = createUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Validation failed", details: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
const user = await db.user.create({ data: result.data });
return NextResponse.json(user, { status: 201 });
}typescriptsafeParse returns a discriminated union, so the success check narrows result.data for you and there is no try/catch to write.
One thing worth doing before validation: guard request.json() itself. A body that is not JSON throws, and an unhandled throw in a route handler is a 500 for what is really a 400. The subscribe endpoint on this site wraps it:
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}typescriptMiddleware Patterns
Next.js middleware in middleware.ts runs before every matching request. For logic that belongs to one group of endpoints, composable wrappers are simpler:
// lib/api/middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { verifyToken } from "@/lib/auth";
type RouteHandler = (
request: NextRequest,
context: { params: Promise<Record<string, string>> }
) => Promise<Response>;
export function withAuth(handler: RouteHandler): RouteHandler {
return async (request, context) => {
const token = request.headers.get("authorization")?.replace("Bearer ", "");
if (!token) {
return NextResponse.json({ error: "Authentication required" }, { status: 401 });
}
try {
const user = await verifyToken(token);
request.headers.set("x-user-id", user.id);
return handler(request, context);
} catch {
return NextResponse.json({ error: "Invalid or expired token" }, { status: 401 });
}
};
}
export function withErrorHandling(handler: RouteHandler): RouteHandler {
return async (request, context) => {
try {
return await handler(request, context);
} catch (error) {
console.error("API Error:", error);
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: "Validation failed", details: error.flatten().fieldErrors },
{ status: 400 }
);
}
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
};
}
export const withApi = (handler: RouteHandler) => withErrorHandling(withAuth(handler));typescript// app/api/admin/stats/route.ts
export const GET = withApi(async (request) => {
const userId = request.headers.get("x-user-id")!;
return NextResponse.json(await getAdminStats(userId));
});typescriptCaching: What Actually Changed
This is the section most older tutorials get wrong, including the previous version of this one.
Route handlers are not cached by default. GET handlers were static by default up to Next.js 14. That changed in v15.0.0-RC: GET became dynamic, and the same release made context.params a promise. Other methods were never cached and still are not, even when they sit in the same file as a cached GET.
To opt a GET handler back into caching:
export const dynamic = 'force-static';
export async function GET() {
const res = await fetch('https://api.example.com/products');
return Response.json(await res.json());
}typescriptNext.js 16 adds a second, finer-grained path. With cacheComponents: true in next.config.ts, you mark the cacheable function rather than the whole route:
import { cacheLife } from 'next/cache';
export async function GET() {
return Response.json(await getProducts());
}
async function getProducts() {
'use cache';
cacheLife('hours');
return db.query('SELECT * FROM products');
}typescriptThe difference matters when a handler mixes cacheable and per-request data. force-static is all or nothing; 'use cache' lets the expensive query be shared while the response around it stays dynamic. Note the cost: turning on cacheComponents surfaces build errors for uncached data that is not wrapped in Suspense, so it is not a flag you flip on a Friday.
For invalidation, revalidateTag purges specific data and revalidatePath refreshes a page:
import { revalidateTag } from "next/cache";
export async function POST(request: Request) {
const { secret } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return NextResponse.json({ error: "Invalid secret" }, { status: 401 });
}
revalidateTag("products");
return NextResponse.json({ revalidated: true });
}typescriptServer Actions vs Route Handlers
Here is the decision the docs are deliberately vague about, and the reason most app/api/ folders are larger than they need to be.
Use a server action when
- The caller is your own UI. Form submissions, button clicks, inline edits. You skip writing an endpoint, a fetch call and a response handler, and you skip inventing a URL for something only one component calls.
- Progressive enhancement matters. A server action can be the
actionof a plain<form>, so it works before the JavaScript loads. - You want the argument types checked. A server action is a function call. Rename a field and the compiler tells you. Change a route handler's expected body and nothing tells you until runtime.
// app/actions/posts.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.post.create({ data: { title, content } });
revalidatePath("/blog");
}typescript// app/blog/new/page.tsx
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">Publish</button>
</form>
);
}tsxUse a route handler when
- Something that is not your UI calls it. Mobile apps, webhooks, third-party integrations, a public API. Stripe cannot invoke a server action.
- You need HTTP itself. Specific status codes, custom headers, content negotiation, streaming, CORS preflight. A server action gives you a function return value, not a response.
- More than one consumer shares the logic.
- The resource is genuinely RESTful. CRUD on a model maps cleanly onto methods and URLs.
The practical rule
If you had to invent a URL for it, it probably wants to be a server action. If the URL already had to exist because something outside your app calls it, it is a route handler. Plenty of production apps run both.
Running Route Handlers Somewhere Other Than Vercel
Almost every route-handler tutorial assumes Vercel, where each handler becomes a serverless function and the platform details are invisible. This blog runs on Cloudflare Workers through the OpenNext Cloudflare adapter, and two things change.
process.env is not where your secrets are. On a Worker, bindings and secrets arrive on the request context, not the process environment. The handler has to ask for them:
import { getCloudflareContext } from '@opennextjs/cloudflare';
function getEnv(key: string): string | undefined {
try {
const { env } = getCloudflareContext();
return (env as Record<string, string>)[key];
} catch {
return process.env[key];
}
}typescriptThe catch is the local-dev path: next dev has no Worker context, so it falls back to process.env. Writing it the other way round, reading process.env first, works locally and silently returns undefined in production.
The incremental cache needs somewhere to live. There is no shared filesystem between Worker invocations, so the cache has to be an explicit binding:
// open-next.config.ts
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
export default defineCloudflareConfig({
incrementalCache: r2IncrementalCache,
});typescriptWithout that override, revalidateTag and revalidatePath have nothing to invalidate and every "cached" response is recomputed.
The portability claim earlier in this post is real, but it is a claim about the Request/Response surface, not about the framework's caching and configuration surface. Those still need per-platform work.
What Route Handlers Cost
Route handlers are the right answer often enough that it is easy to miss what you take on with each one.
- You own the boundary. Validation, auth, error shape, CORS, rate limiting. A server action inherits your app's session and needs none of it. This site's subscribe endpoint is a single POST and it is 100 lines, most of them boundary handling.
- The types stop at the network.
z.infergives you a type on both sides, but nothing checks that the client actually sends what the handler parses. That gap does not exist for a server action. - Cold starts are a property of your host, not of Next.js. They differ by an order of magnitude between a Node serverless function, an edge runtime and a long-running
next startprocess. Measure yours; do not copy a number out of a blog post, including this one. - Testing is fiddly but not hard. You import the exported function and hand it a
Request. No Next.js test harness is needed, which is the upside of the web-standard API. - An endpoint is a permanent public surface. Once something external depends on
/api/users, its shape is a contract. A server action can be renamed on a Tuesday.
A Complete CRUD Route
// lib/validations/task.ts
import { z } from "zod";
export const createTaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
dueDate: z.string().datetime().optional(),
});
export const updateTaskSchema = createTaskSchema.partial();typescript// app/api/tasks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { createTaskSchema } from "@/lib/validations/task";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const where: Record<string, unknown> = {};
const priority = searchParams.get("priority");
const status = searchParams.get("status");
if (priority) where.priority = priority;
if (status) where.status = status;
const tasks = await db.task.findMany({ where, orderBy: { createdAt: "desc" } });
return NextResponse.json({ data: tasks });
}
export async function POST(request: Request) {
const body = await request.json();
const result = createTaskSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Validation failed", details: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
const task = await db.task.create({ data: result.data });
return NextResponse.json({ data: task }, { status: 201 });
}typescript// app/api/tasks/[id]/route.ts
import { NextResponse } from "next/server";
import { updateTaskSchema } from "@/lib/validations/task";
interface RouteParams {
params: Promise<{ id: string }>;
}
export async function PATCH(request: Request, { params }: RouteParams) {
const { id } = await params;
const body = await request.json();
const result = updateTaskSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Validation failed", details: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
try {
const task = await db.task.update({ where: { id }, data: result.data });
return NextResponse.json({ data: task });
} catch {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
}typescriptPlural resource names, methods that mean what they say, one response envelope, validation at the edge. Nothing clever, which is the point.
One Response Shape
Pick a single envelope and enforce it with helpers, so the client can model it as a discriminated union:
// lib/api/response.ts
export function successResponse<T>(data: T, status = 200) {
return NextResponse.json({ data, error: null }, { status });
}
export function errorResponse(message: string, status: number, details?: unknown) {
return NextResponse.json({ data: null, error: { message, details } }, { status });
}typescriptEvery success is { data, error: null }, every failure is { data: null, error }, and the client narrows on which one is non-null.
Streaming Responses
Route handlers return a Response, so a ReadableStream body works with no framework support:
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
const data = JSON.stringify({ count: i });
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
await new Promise((resolve) => setTimeout(resolve, 1000));
}
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}typescriptConsume it with EventSource, or with the Fetch reader inside a custom React hook.
FAQ
What is the difference between API routes and route handlers in Next.js?
API routes are the pages/api/ pattern from the Pages Router. Route handlers are the App Router equivalent: app/api/.../route.ts files with named HTTP method exports. Route handlers use the standard Web Request and Response instead of Next.js-specific objects, so a handler that sticks to those runs on other runtimes unchanged. Use route handlers in new App Router projects.
Are Next.js route handlers cached?
Not by default. GET handlers were static by default up to Next.js 14 and became dynamic in v15.0.0-RC. Opt back in with export const dynamic = 'force-static', which caches the whole route, or with the 'use cache' directive on an individual function when cacheComponents is enabled in Next.js 16. Non-GET methods are never cached.
When should I use server actions instead of API routes?
Use a server action when the caller is your own UI, especially for form submissions. You avoid inventing a URL, you get argument type checking, and the form works without JavaScript. Use a route handler when something outside your app calls the endpoint, when you need control over the HTTP response itself, or when several consumers share the same logic.
Why do I get "params should be awaited" in Next.js 15 or 16?
Because context.params became a Promise in v15.0.0-RC, and Next.js 16 removed synchronous access to request-time APIs entirely. Type the parameter as Promise<{ id: string }> and await it inside the handler. The same applies to searchParams, cookies(), headers() and draftMode().
How do I handle CORS in Next.js route handlers?
Set the headers on the response yourself, and export an OPTIONS handler returning those headers with a 204 for preflight. next.config.ts also takes a headers configuration if you want CORS applied to a URL pattern without touching handler code. There is no built-in CORS middleware.
Are Next.js API routes serverless functions?
That depends entirely on where you deploy. On Vercel each handler is deployed as a serverless function. Under next start they run inside one long-lived Node process. On Cloudflare Workers via OpenNext they run in the Workers runtime, where process.env is not the binding path and the incremental cache has to be pointed at real storage. The handler code is portable; the deployment behaviour is not.
How do I test a route handler?
You can import the exported function and call it with a Request you construct, then assert on the returned Response, and for pure logic that is enough. It has two blind spots worth knowing about: route segment config is invisible to a direct call, and you supply context.params yourself, so a handler that reads them synchronously passes if your test hands it a plain object. Running the handler through Next's own resolvers with next-test-api-route-handler costs the same and closes both. We seeded ten defects and measured which method caught which in testing route handlers.
Sources
Checked 2026-08-20.
- Next.js route handlers reference - version history confirming that
GETcaching changed from static to dynamic inv15.0.0-RCand thatcontext.paramsbecame a promise in the same release. - Next.js route handlers guide - that handlers are not cached by default, that
dynamic = 'force-static'opts aGETback in, and that other methods are never cached. - Upgrading to Next.js 15 - the
GETcaching change. - Upgrading to Next.js 16 - removal of synchronous request APIs, and the move from
experimental.dynamicIOto top-levelcacheComponents. - The
use cachedirective - per-function caching andcacheLife. - OpenNext Cloudflare adapter - the deployment target used for the Cloudflare section of this post.