Next.js Route Handlers: request-time execution, caching boundaries, and production-correct APIs

App Router GET handlers can be folded into static optimization unless you cross a dynamic boundary. This article maps segment config, fetch caching, cookies and headers, and connection() to predictable request-time behavior.

Autor: Matheus Palma8 Min. Lesezeit
Next.jsTypeScriptBackendAPI designSoftware architectureWeb development

You add app/api/health/route.ts with a GET handler that pings Redis and returns JSON. Locally, every refresh shows fresh latency numbers. After deploy, your uptime monitor reports intermittent failures while the dashboard looks fine—until you realize the handler sometimes ran during the build, cached its response, and now the CDN serves a snapshot of “healthy” from last Tuesday. In consulting work on Next.js codebases, this class of bug is less about Redis and more about where Next.js is allowed to stop treating code as request-scoped.

Route Handlers are ordinary modules that participate in the same static vs dynamic analysis as pages and layouts. If nothing in the module graph forces request-time execution, the framework is free to prerender, memoize, or otherwise reuse work. That is a feature for marketing pages; it is a foot-gun for operational endpoints.

This article gives a decision map: which APIs pin a handler to a real Request, how fetch caching interacts with handler code, when export const dynamic / revalidate still matter, and why connection() exists as an explicit escape hatch. The goal is not to memorize tables from the docs, but to reason about boundaries so APIs stay honest under load and across releases—including Next.js 16 projects that may adopt stricter caching modes over time.

Mental model: handlers are routes, not “always Express”

In the App Router, a file at app/.../route.ts defines HTTP methods on a URL. Conceptually:

  • A POST, PUT, PATCH, or DELETE handler is tied to mutating verbs; Next.js treats these as dynamic in practice because they exist to observe a body and side effects.
  • A GET handler looks like a cheap read. If its implementation only touches pure inputs (constants, deterministic transforms) and cached remote reads with long TTLs, the bundler and runtime can treat it like any other cacheable data producer.

The mistake is assuming “it lives under /api” implies per-request semantics. The framework optimizes graphs, not folder names.

Dynamic boundaries: what actually pins execution to a request

Dynamic behavior is introduced when the handler (or something it synchronously imports) uses APIs that require an incoming request context or opt out of static caching in documented ways. Typical triggers include:

  • cookies() and headers() from next/headers — they need the live request envelope.
  • request.url, request.headers, request.cookies — reading these ties the response to a specific client request (contrast with a handler that returns the same JSON for all callers).
  • searchParams on page routes — not directly on route handlers, but the same idea applies: URL variance is request data.
  • fetch with cache: 'no-store' (or legacy equivalents that disable data cache reuse) — you are declaring that upstream data is not safe to reuse across invocations.
  • Non-deterministic or environment-bound I/O you do not want deduplicated — for example hitting a private metrics backend where staleness is incorrect, not merely unfashionable.

When none of the above appear, a GET Route Handler can be eligible for static treatment: build-time evaluation, aggressive caching, or reuse consistent with your deployment’s CDN rules. That is why a health check that mutates nothing but still must run now needs an explicit boundary.

connection() as an explicit “I need a real request” signal

Next.js exposes connection() from next/server. The type definition states the contract clearly: during prerendering it does not resolve; when there is a real user request in flight, it resolves immediately. Awaiting it at the top of a handler (before side effects you care about) is the idiomatic way to say “do not treat the remainder of this handler as build-time-safe content.”

import { connection } from "next/server";
import { NextResponse } from "next/server";

export async function GET() {
  await connection();
  const ping = await measureRedisPingMs();
  return NextResponse.json({ ok: true, redisPingMs: ping });
}

Use this when the handler is logically dynamic but looks static to static analysis—for example a tiny JSON response with no headers() usage, or a probe you want tree-shaken conservatively.

Segment config: dynamic, revalidate, and route-level contracts

Route Handlers support route segment config exports alongside handlers. Two that matter most for correctness:

  • export const dynamic = 'force-dynamic' — opts the route out of static optimization; every matching request executes the handler path you wrote (subject to platform timeouts and runtime limits).
  • export const revalidate = <seconds> — for cacheable GET semantics, sets a time-based freshness window when the route participates in caching layers that honor it.

These are coarse levers. They do not replace good API design—wrong Cache-Control on the Response object can still confuse browsers—but they align Next’s build and render pipeline with your intent.

A pattern that has held up on production BFFs and public JSON endpoints:

export const dynamic = "force-dynamic";

export async function GET(request: Request) {
  const session = await readSessionFromCookies(); // dynamic boundary via cookies
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  const data = await loadForUser(session.sub);
  return NextResponse.json(data, {
    headers: { "Cache-Control": "private, no-store" },
  });
}

Here the segment config is almost redundant if readSessionFromCookies already calls cookies(), but redundancy is documentation for the next engineer—and it survives small refactors that might otherwise remove the only dynamic call.

fetch inside Route Handlers: separate “handler static” from “data fresh”

Even when the handler itself is dynamic, fetch has its own caching contract. Historically, many teams were surprised that fetch inside Server Components defaulted to aggressive caching; in recent Next.js lines the default leans toward fresh remote reads unless you opt in, but you should still read your installed version’s release notes when upgrading—this is an area of active refinement.

Practical rules that stay stable across those shifts:

  1. User-specific reads — pass cache: 'no-store' (or an explicit short revalidate only if you can tolerate staleness and have a clear invalidation story).
  2. Reference data (feature flags, taxonomy lists) — use revalidate windows or tag-based revalidation if you adopt that API, and document the maximum staleness your product accepts.
  3. Never rely on implicit caching to enforce authorization. If a URL is the same for two users, a shared cache entry is a confidentiality bug waiting to happen unless private/Vary and cache keys are correct—topics that intersect with read-your-writes guarantees on the wider web stack.

Practical example: versioned public read + session-bound private read

Consider a small API surface: a public, versioned configuration blob that may be cached at the edge, and a session-bound GET that must never be cached for the wrong user. The public route uses a bounded staleness window; the private route combines cookies(), no-store fetches, and response headers.

// app/api/public/config/v1/route.ts
import { NextResponse } from "next/server";

export const revalidate = 60;

export async function GET() {
  const upstream = await fetch("https://config.vendor.example/v1/app.json", {
    next: { revalidate: 60 },
  });
  if (!upstream.ok) {
    return NextResponse.json(
      { error: "Upstream unavailable" },
      { status: 502 }
    );
  }
  const body = await upstream.json();
  return NextResponse.json(body, {
    headers: {
      "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120",
    },
  });
}
// app/api/me/preferences/route.ts
import { cookies } from "next/headers";
import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";

export async function GET() {
  const jar = await cookies();
  const token = jar.get("session")?.value;
  if (!token) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const res = await fetch(`${process.env.USER_API}/preferences`, {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store",
  });
  if (!res.ok) {
    return NextResponse.json({ error: "Upstream error" }, { status: 502 });
  }
  const prefs = await res.json();
  return NextResponse.json(prefs, {
    headers: { "Cache-Control": "private, no-store" },
  });
}

The split makes the caching contract obvious in code review: one file is safe to sit behind a shared cache; the other is pinned to identity and must not be reused across users.

Common mistakes and pitfalls

  • Assuming /api is exempt from static analysis — folder naming does not define dynamism; dependencies and config do.
  • Health checks and metrics without await connection() — probes that must reflect now should declare a request boundary early, or they may be evaluated when no request exists.
  • Caching personalized GET JSON as public — even when Next.js does the right thing, your CDN might still cache too aggressively if response headers disagree with reality.
  • Upgrades without re-reading release notes — segment config and default fetch semantics evolve. A handler that was accidentally safe on Next 14 can become hotter or staler on Next 16 depending on direction; treat caching as versioned infrastructure, not boilerplate.
  • Using force-dynamic as a universal deodorant — it works, but it removes optimization you might have wanted for genuinely public reads. Prefer narrow boundaries (revalidate, targeted fetch options, connection() only where needed).

Conclusion

Route Handlers are where HTTP semantics meet the App Router’s compile-time story. Treat every GET as a candidate for reuse until you prove otherwise; treat every authenticated read as private and non-cacheable unless you have a keyed invalidation design; and treat connection(), cookies() / headers(), and fetch cache options as explicit contracts with the framework, not implementation details.

When teams internalize that model, Next.js stops feeling “magical” and starts behaving like any other production edge: predictable under load, easier to observe, and safe to evolve across framework upgrades. If you are tightening APIs ahead of scale or auditing a migration to Next.js 16, mapping these boundaries first saves weeks of reactive debugging later.

Newsletter abonnieren

E-Mail erhalten, wenn neue Artikel erscheinen. Kein Spam — nur neue Beiträge von diesem Blog.

Über Resend. Abmeldung in jeder E-Mail möglich.