open atlas
↑ Back to track
Next.js, zero to senior NEXT · 03 · 01

Route handlers: route.ts as your HTTP surface — verbs, params, and the Next 15 caching flip

route.ts exports GET/POST and friends as functions over web Request/Response. Dynamic segments arrive as awaited params. Next 15 flipped GET caching to dynamic-by-default — force-static is opt-in. Handlers are your public HTTP surface; server actions are internal mutations.

NEXT Middle ◷ 17 min
Level
FoundationsJuniorMiddleSenior

A team on Next.js 14 ships app/api/feature-flags/route.ts — a GET handler that reads flags from Postgres and returns JSON. It works in dev. In production, support starts a thread: “we flipped the kill switch for the broken checkout flow twenty minutes ago and users still see it.” The handler is fine; the query is fine. The problem is that nobody fetched anything: in Next 14 a GET route handler with no dynamic API usage was statically cached at build time, so production was serving a JSON snapshot from the last deploy. The team patches it with export const dynamic = 'force-dynamic' and moves on. A year later they upgrade to Next 15, where the default flipped to uncached — and a different handler, one that was quietly relying on build-time caching to shield an expensive aggregate query, starts hitting the database on every request. Same file convention, two opposite production incidents. Route handlers are simple; their caching defaults have history.

route.ts: HTTP verbs as exported functions

A route handler is the App Router’s replacement for pages/api: a file named route.ts inside any app/ segment, exporting async functions named after HTTP methods — GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Each function receives a web-standard Request (or Next’s extended NextRequest) and returns a web-standard Response (or NextResponse). There is no Express-style res object to mutate — you return the response, which is what makes handlers composable and trivially testable: call the function, assert on the Response.

// app/api/items/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const page = request.nextUrl.searchParams.get("page") ?? "1";
  const items = await db.items.findPage(Number(page));
  return NextResponse.json({ items });
}

export async function POST(request: NextRequest) {
  const body = await request.json();          // web API: one-shot body read
  const created = await db.items.create(body);
  return NextResponse.json(created, { status: 201 });
}

Two contract rules matter in practice. First, route.ts and page.tsx cannot coexist in the same segment — a URL resolves to exactly one of them, and the build fails if both claim it. Second, if you export some methods and a client calls one you didn’t export, Next returns 405 Method Not Allowed for you — except OPTIONS, which Next implements automatically (listing your allowed methods) unless you define it yourself.

Because the surface is the web Request/Response pair, everything you know from fetch transfers: request.headers.get('authorization'), await request.text() for raw bodies (the way Stripe-style webhook signature verification needs them — parse to JSON and the signature check fails, because signing happens over exact bytes), request.formData() for uploads. NextRequest adds conveniences on top: request.nextUrl (a parsed URL with searchParams), and request.cookies for read access.

Dynamic segments: params is a Promise now

Handlers nest in the same folder tree as pages, so dynamic segments work identically: app/api/items/[id]/route.ts matches /api/items/42. The segment values arrive as the second argument — and since Next 15 that argument’s params is a Promise you must await, part of the same async-request-APIs migration that made cookies() and headers() async:

// app/api/items/[id]/route.ts
export async function GET(
  _request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;               // Next 15: await it
  const item = await db.items.find(id);
  if (!item) return Response.json({ error: "not found" }, { status: 404 });
  return Response.json(item);
}

The synchronous form still works in 15 with a deprecation warning, which is exactly the kind of thing that lulls a codebase: it compiles, it runs, and the migration debt accretes until a major version makes it a hard error. Codemods exist (next-async-request-api); running them beats hand-fixing forty handlers later.

Failure mode: reading the body twice. await request.json() consumes the stream; a second read throws. Teams hit this when a logging wrapper reads the body “just to log it” and the actual handler then gets an exhausted stream. If you must read twice, request.clone() first — that is the web-platform answer, not a Next quirk.

Quiz

A webhook handler does `const event = await request.json()` and then passes `request` to a shared verifySignature(request) helper that calls `await request.text()`. The signature check always fails or throws. Why?

The caching flip: Next 14 cached GET, Next 15 does not

This is the part with history, and the Hook’s two incidents are its two halves. In Next 14 and earlier, a GET route handler was treated like a static page: if it used no dynamic API (no reading the request object meaningfully, no cookies()/headers(), no other exported methods alongside it), Next evaluated it at build time and served the cached result. A flags endpoint, a status endpoint, a “current price” endpoint — all silently frozen at deploy time. The mental model violation is what made it nasty: nothing about fetch('/api/feature-flags') looks cacheable to a reader.

Next 15 flipped the default: GET handlers are uncached — every request executes the function. If you want the static behavior, you opt in per file with the segment config:

// Opt back into build-time static caching (Next 15+)
export const dynamic = "force-static";
export const revalidate = 60;   // optional: ISR-style re-generation every 60s

The tradeoff is honest in both directions. Dynamic-by-default matches what developers expect from “an API endpoint” and kills the stale-snapshot class of bugs; the price is that handlers which were leaning on the old default as a free cache — the expensive aggregate in the Hook — become per-request database load after the upgrade. The fix is to make the caching explicit: force-static plus revalidate for data that tolerates staleness, or an application-level cache (unstable_cache, Redis) when you need request awareness and cheap reads. Defaults migrate; explicit config survives upgrades.

Route handler or server action?

Both run server-side code in the same codebase, so teams blur them. The decision rule: a route handler is a public HTTP contract; a server action is an internal RPC for your own UI.

Reach for a route handler when something other than your own Next.js components calls the endpoint: third-party webhooks, a mobile app, a partner integration, an OAuth callback, anything that needs a stable URL, explicit status codes, content negotiation, or non-JSON responses (files, streams, redirects with Set-Cookie). Handlers are also what you can hit with curl in an incident.

Reach for a server action when your own form or component mutates data: you get typed arguments instead of hand-parsing bodies, progressive enhancement on forms, and built-in integration with revalidatePath/revalidateTag. Two consequences follow that surprise people. Actions execute sequentially per client — they are not the tool for parallel data fetching. And actions compile to POST endpoints with framework-generated identifiers — they are still on the network, still attacker-reachable, and need the same authorization checks as any handler; “internal” describes the intended caller, not the security boundary.

The anti-pattern is the team that builds app/api/.../route.ts for every form submit and then hand-writes fetch calls, loading states, and cache invalidation that actions would have given them — or the inverse, exposing a server action as a de-facto public API and discovering its identifier-based contract breaks on every deploy.

Quiz

After upgrading 14 → 15, a /api/leaderboard GET handler that aggregates 2M rows goes from negligible load to ~40 q/s against Postgres, and p95 latency on the endpoint triples. What happened and what is the right-shaped fix?

Recall before you leave
  1. 01
    What is the contract of a route.ts file — exports, arguments, return, and the two structural rules?
  2. 02
    How did GET handler caching change between Next 14 and 15, and what breaks in each direction?
Recap

A route handler is a route.ts file exporting HTTP verbs as async functions over the web platform pair: Request in, Response out, no mutable res object. It owns its segment exclusively — route.ts and page.tsx cannot both claim a URL — and Next fills in 405s for missing methods and an automatic OPTIONS. NextRequest adds nextUrl with parsed searchParams and cookie access; bodies are one-shot streams, so webhook handlers read text() once, verify the signature over raw bytes, then parse — and anything that needs a second read must clone() first. Dynamic segments work as in pages, with the Next 15 twist that the context’s params is a Promise you await, part of the async request APIs migration with codemod support. The caching history matters operationally: Next 14 froze plain GET handlers at build time, which served stale snapshots of flags and prices and burned teams who assumed an endpoint runs per request; Next 15 inverted the default to dynamic, which un-froze those bugs and simultaneously exposed handlers that had been leaning on the implicit cache to shield expensive queries. The lesson is to write the caching down: force-static plus revalidate when staleness is acceptable, explicit cache layers when it is not — defaults migrate across majors, explicit config does not. Finally, the boundary with server actions: handlers are a public HTTP surface for webhooks, mobile clients, OAuth callbacks, streams, and anything curl-able; actions are typed internal RPC for your own forms with revalidation built in, executing sequentially per client — and despite the word internal they remain attacker-reachable POST endpoints that need the same authorization as any handler. Now when you see a GET endpoint behaving like it’s frozen in time after a deploy, or a handler that silently doubled its database load after an upgrade, you know exactly where to look: the caching default that changed between versions, and the one-line config that makes it explicit.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.