Route Handlers and API Design in Next.js
I walk through how route.ts works, when to choose it over Server Actions, and the API design patterns that hold up as a Next.js app grows.

Most developers transitioning from the Pages Router assume route handlers are just pages/api/ with a new address — same pattern, different folder. That assumption holds right up until you upgrade to Next.js 15, try to read a dynamic route parameter, and get back a Promise where you expected a string.
Route handlers in the App Router are built on the Web Request and Response APIs, not the Node.js req/res pattern from the Pages Router. They interact with Next.js’s caching layer in non-obvious ways, they changed their default caching behavior in v15, and they serve a genuinely different purpose from Server Actions even when both can technically handle a similar mutation.
I’m going to walk through how the file convention works, how to read request data correctly, the v15 changes that catch people off guard, and when a route handler is actually the right tool versus a Server Action.
Series: Part 6 of 6. Start with Part 1: Getting Started with Next.js App Router · Previous: Authentication in Next.js: A Practical Architecture
On this page
- The route.ts file convention
- Reading request data
- Dynamic segments and the Next.js 15 change
- Route handlers vs. Server Actions
- Patterns that break in practice
- Route handlers as the public boundary
The route.ts file convention
Route handlers live anywhere inside the app/ directory — not just under app/api/. The convention is a file named route.ts (or route.js) at whatever path you want to handle. So app/api/posts/route.ts handles /api/posts, and app/webhooks/stripe/route.ts handles /webhooks/stripe.
One constraint worth knowing upfront: you can’t have both route.ts and page.tsx in the same directory. A route handler replaces the page at that path. The build will warn you if both exist.
Each HTTP method becomes a named export:
// app/api/posts/route.ts
export async function GET() {
return Response.json({ posts: [] });
}
export async function POST(request: Request) {
const body = await request.json();
return Response.json({ created: body }, { status: 201 });
}
Next.js supports GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS. If you don’t export OPTIONS, Next.js auto-implements it and fills the Allow response header based on your other exports — which covers most CORS preflight cases without extra code.
The return value is a standard Web API Response. There’s no res.send() or res.json() here. You can use Response.json() for JSON, new Response(text, { headers }) for custom content types, or NextResponse from next/server when you need to set cookies, redirect, or rewrite.
Route handlers were added in Next.js 13.2 as the App Router replacement for pages/api/ routes.
Reading request data
Both argument positions are optional. The first is the request; the second is the route context.
Query parameters. Use NextRequest from next/server to access the convenience methods:
import { type NextRequest } from "next/server";
export function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get("q");
// /api/search?q=hello → query === "hello"
return Response.json({ query });
}
Request body. The standard Web API request.json() handles it:
export async function POST(request: Request) {
const body = await request.json();
return Response.json({ received: body });
}
Headers and cookies. You can read them directly from request.headers, or use the headers() and cookies() helpers from next/headers. The helpers are convenient when the same logic runs in both a route handler and a Server Component:
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const session = cookieStore.get("session")?.value;
return Response.json({ authenticated: !!session });
}
Try this
- Create
app/api/ping/route.tsin a Next.js project.- Export
GETreturningResponse.json({ ok: true, ts: Date.now() }).- Visit
/api/pingin the browser or call it withcurl.Expected result:
{"ok":true,"ts":1712...}— clean JSON with no boilerplate. This confirms the handler is wired correctly before you add any real logic.
Dynamic segments and the Next.js 15 change
Dynamic route segments use the same bracket notation as pages: app/api/posts/[id]/route.ts matches /api/posts/42.
In Next.js 14, the params object was synchronous:
// v14 pattern — does NOT work correctly in v15
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
return Response.json({ id: params.id });
}
In Next.js 15+, params became a Promise. You must await it before destructuring:
// app/api/posts/[id]/route.ts — v15+
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
return Response.json({ id });
}
This catches a lot of people during upgrades. The old pattern doesn’t throw an error in v15 — it silently serializes an unresolved Promise into the JSON response. You get {"id":"[object Promise]"} instead of {"id":"42"}, which is hard to trace until you know what to look for.
The other v15 change worth knowing: GET handlers are no longer statically cached by default. In v14, a GET route handler with no dynamic APIs would be pre-rendered at build time. In v15, all handlers are dynamic unless you explicitly opt in. If you relied on that implicit static behavior for performance, you’ll want to add explicit cache configuration — the data fetching and caching post covers the relevant directives:
export const revalidate = 60; // re-fetch at most every 60 seconds
export async function GET() {
const data = await fetch("https://api.example.com/posts");
return Response.json(await data.json());
}
Check this before moving on
- In Next.js 15, you’re awaiting
paramsbefore accessing route segment values - You haven’t assumed GET handlers are cached — they’re dynamic unless you set
revalidateordynamic - You’re returning a standard
Responseobject, not callingres.json()from the Pages Router pattern
Route handlers vs. Server Actions
This is the design decision that matters most in a Next.js app.
Server Actions use the 'use server' directive and run via POST under the hood. They’re designed to be called from your React component tree — forms with an action prop, event handlers inside Client Components, or useActionState. They handle mutations and can trigger cache revalidation directly. By design, they dispatch one call at a time.
Route handlers are HTTP endpoints. They respond to any client that can make an HTTP request — a browser, a mobile app, a webhook from a payment processor, a cron job. They’re the right surface when something outside your Next.js UI needs to reach your backend.
| Situation | Recommended | Reason |
|---|---|---|
| Form submission from your UI | Server Action | Built for the React tree; revalidates cache in one step |
| REST endpoint for a mobile client | Route handler | External callers need a real HTTP endpoint |
| Webhook receiver | Route handler | Third-party services POST to a URL, not a React action |
| Mutation from a dashboard button | Server Action | Lives inside the component tree |
| File or CSV download | Route handler | Needs full control over Content-Type and streaming |
| Streaming text or SSE | Route handler | Server Actions don’t support streaming responses |
The clearest rule: if only your own Next.js UI triggers it, a Server Action is probably simpler. If anything outside your app touches it, use a route handler.
There’s a grey zone — a mutation that your UI triggers and an external client also needs. In that case, put the logic in a shared service function and call it from both. Don’t duplicate the implementation across a Server Action and a route handler.
Patterns that break in practice
Not awaiting params in Next.js 15. This is the most common migration issue. The route doesn’t crash; it silently serializes the unresolved Promise into your response. Catch it by inspecting your API responses carefully after upgrading — {"id":"[object Promise]"} is the tell.
Missing CORS headers. If a browser client on a different origin calls your route handler, you need explicit CORS headers. Next.js doesn’t add them automatically. The OPTIONS handler is where you respond to preflight requests:
export async function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "https://your-client.com",
"Access-Control-Allow-Methods": "GET, POST",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
Without an OPTIONS response, the browser blocks the request before it reaches your GET or POST handler. The server logs show a 404 or 200 — and you’re left confused because the endpoint clearly exists.
Assuming middleware covers API auth. Middleware is a useful place to protect page routes and redirect unauthenticated requests. It’s not a reliable auth layer for API routes unless your middleware.ts matcher explicitly covers them. And even when it does, route handlers can be invoked in ways that sidestep the matcher in certain configurations. Always validate auth inside the handler itself.
Unhandled errors returning HTML. If an uncaught exception escapes a route handler, Next.js returns a 500 with its default error page as HTML. Any client expecting JSON will fail to parse that response. Wrap the handler body:
export async function POST(request: Request) {
try {
const body = await request.json();
// ... your logic
return Response.json({ ok: true });
} catch {
return Response.json({ error: "Invalid request" }, { status: 400 });
}
}
Route handlers as the public boundary
The mental model I find most useful: route handlers are where your Next.js app speaks to the outside world. Server Components read data silently. Server Actions mutate data from UI interactions. Route handlers expose a surface that other systems can call directly.
That framing changes how you design them. Keep handlers thin — validate the request, call a service function, return a response. Business logic belongs in service functions, not in route.ts. If the same logic needs to run from both a Server Action and an API endpoint, put it in a shared function and call it from both places.
Middleware sits in front of all of this, but its scope is narrow. Use middleware.ts for cross-cutting concerns: redirecting unauthenticated page visits, injecting request headers, rewriting paths. Don’t use it as your only auth guard for API routes, and don’t put application logic there.
Route handlers are built on the Web Request and Response APIs — the same APIs that work across edge runtimes, Node.js, and browser service workers. Writing handlers against those standards rather than Node.js-specific objects is what makes them portable when you need to move to the edge.
The thing that changes when you internalize this: you stop treating route handlers as a necessary concession for cases Server Actions can’t cover, and start seeing them as the deliberate API contract with the outside world. Design them with the same care you’d give a public API surface — because for anything outside your Next.js app, that’s exactly what they are.