DEV Community

Cover image for Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16
Parsa Jiravand
Parsa Jiravand

Posted on Originally published at bestpractic.org

Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16

Picture a Next.js 14 app whose GET /api/products handler reads prices from the database. A price drops from $40 to $35, and the API keeps answering $40: Next.js 14 ran the handler once at build time and serves that saved response. Upgrade the same file to Next.js 15 and the stale price is gone, and so is the cache. Every request now runs the query. Same code, opposite behaviour.

That's a scenario, not an incident report, but each half follows from its version's documented default. Next.js Route Handlers switched GET from static to dynamic in version 15, and 16 added a second caching model on top. This episode covers what changed, what it means for code and tutorials from the 14 era, and how to cache a Route Handler on purpose today.

What you'll learn

By the end of this article you'll be able to:

  • Explain what changed for GET Route Handlers between Next.js 14 and 15, and why a handler migrated from 14 now runs on every request
  • Cache a GET handler on purpose in Next.js 16: force-static and revalidate under the default model, a "use cache" helper under cacheComponents
  • Predict when a GET handler is prerendered at build time under cacheComponents, and what stops it
  • Read dynamic segments correctly now that params is a Promise and synchronous access is gone
  • Stream a response, and decide when a Route Handler is the right tool instead of a Server Action or a page

Who this is for

You've built at least one App Router route and used fetch inside a Server Component. No Pages Router experience is needed.

This is written against Next.js 16.3 (npm latest is 16.3.6; docs verified September 2026). Next.js 16 has two caching models: the default one, and Cache Components, enabled with cacheComponents: true in next.config. A fresh create-next-app project doesn't set the flag (its generated config is empty), so this article covers both, plus the Next.js 14 behaviour you'll still meet in older code and tutorials.

Table of contents

The problem: Next.js Route Handlers changed their default in 15

Here's the handler from the scenario, written the way most people write their first one:

// app/api/products/route.ts
import { db } from "@/lib/db";

export async function GET() {
  const products = await db.query("SELECT id, name, price FROM products");
  return Response.json(products);
}
Enter fullscreen mode Exit fullscreen mode

On Next.js 14, this is the $40 bug. The Next.js 14 docs say so directly: "Route Handlers are cached by default when using the GET method with the Response object." The ways out were reading the Request object, using another HTTP method, calling cookies() or headers(), or setting a segment config option. This handler does none of those, so Next.js 14 evaluated it during next build, and the database stopped mattering until the next deploy.

On Next.js 15, the default flipped. From the Next.js 15 release notes (October 2024): "In Next 14, Route Handlers that used the GET HTTP method were cached by default unless they used a dynamic function or dynamic config option. In Next.js 15, GET functions are not cached by default." The route.js reference for 16.3.6 records the same change in its version history: "The default caching for GET handlers was changed from static to dynamic" (v15.0.0-RC).

So on Next.js 16, under the default model, that handler runs on every request: correct prices, and one query per request where there used to be none.

What the change means for code migrated from 14

  • Handlers that were quietly static now run per request. Correctness improves; load and latency change. If an endpoint should be cached, in 15+ you have to say so.
  • export const dynamic = "force-dynamic" is often a leftover. The Next.js 14 Route Handlers page opened with exactly that line, so plenty of 14-era files carry it. Under the default model in 16 it's redundant for a GET. Once you enable cacheComponents, it breaks: "route segments that still export dynamic, revalidate, or fetchCache will error."
  • Tutorials still teach the old default. Anything written for 14 that says "GET handlers are cached unless…" describes behaviour that ended in 15. The fix for 14 (opt out) is the opposite of the fix for 16 (opt in), so check the version before you copy one.

The mental model

In Next.js 16, a GET Route Handler runs on every request unless something states otherwise, and what counts as "stating otherwise" depends on which caching model you're on.

Under the default model, the statement is a segment config line. The docs: "Route Handlers are not cached by default. You can, however, opt into caching for GET methods," with export const dynamic = 'force-static'.

Under Cache Components, the statement is the code itself: "GET Route Handlers follow the same model as normal UI routes in your application. They run at request time by default, can be prerendered when they don't access uncached or runtime data, and you can use use cache to include uncached data in the static response."

Side by side:

Next.js 14 Next.js 15/16, default model Next.js 16, cacheComponents: true
GET with no dynamic input Evaluated at build time, cached Runs on every request Prerendered only if it touches no uncached or runtime data
The scenario's DB query Cached (the $40 bug) Runs per request Runs per request (a DB query stops prerendering)
Cache it on purpose Already cached; revalidate for a window export const dynamic = "force-static" A "use cache" helper with cacheLife
Non-GET methods Never cached Never cached Never cached

The last row never changes: "Other supported HTTP methods are not cached, even if they are placed alongside a GET method that is cached, in the same file."

Hold on to one inversion and the rest of this article follows: in 14 you wrote config to get out of the cache; in 16 you write config, or "use cache", to get in.

Caching Next.js Route Handlers on purpose in 16

The file convention

A Route Handler lives in a route.ts (or .js) file under app/ and exports one async function per HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Next.js reads the exported names; there's no router table. A method you didn't export gets 405 Method Not Allowed, and if you don't export OPTIONS, Next.js implements it and sets the Allow header for you.

// app/api/products/route.ts
export async function GET(request: Request) {
  return Response.json({ ok: true });
}

export async function POST(request: Request) {
  const body = await request.json();
  return Response.json({ received: body }, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

Key concept: a route.ts and a page.tsx can't share a route segment. app/page.js plus app/route.js is a conflict; app/page.js plus app/api/route.js is fine.

Request and response

Plain Web Request/Response are enough for a JSON API. The request argument is actually a NextRequest, which adds request.nextUrl (a parsed URL) and request.cookies.

import type { NextRequest } from "next/server";

export async function GET(request: NextRequest) {
  const query = request.nextUrl.searchParams.get("q");
  return Response.json({ query });
}
Enter fullscreen mode Exit fullscreen mode

Key concept: reading the request changes nothing under the default model, because the handler already runs per request. Under cacheComponents it matters: "request object properties (like req.url, request.headers, request.cookies, request.body)" are on the docs' list of things that stop a GET handler from prerendering.

Dynamic segments: params is a Promise

For app/api/products/[id]/route.ts, the handler's second argument carries params, and since Next.js 15 it's a Promise you await:

// app/api/products/[id]/route.ts
import { db } from "@/lib/db";

export async function GET(
  request: Request,
  ctx: RouteContext<"/api/products/[id]">
) {
  const { id } = await ctx.params;
  const product = await db.get(id);
  if (!product) {
    return Response.json({ error: "not found" }, { status: 404 });
  }
  return Response.json(product);
}
Enter fullscreen mode Exit fullscreen mode

RouteContext is a global type helper generated by next dev, next build or next typegen; the hand-written { params }: { params: Promise<{ id: string }> } is equivalent.

Next.js 15 made params, cookies() and headers() async with a grace period: "these APIs can temporarily be accessed synchronously, but will show warnings in development and production until the next major version." That major was 16: "synchronous access is fully removed." If your upgrade skipped the migration, run the codemod: npx @next/codemod@canary next-async-request-api .

Default model: opt in with force-static

This replaces "do nothing" from 14. The docs: "To cache a GET method, use a route config option such as export const dynamic = 'force-static' in your Route Handler file."

// app/api/products/route.ts
import { db } from "@/lib/db";

export const dynamic = "force-static"; // evaluate once, cache the response
export const revalidate = 3600; // optional: refresh at most once an hour (seconds)

export async function GET() {
  const products = await db.query("SELECT id, name, price FROM products");
  return Response.json(products);
}
Enter fullscreen mode Exit fullscreen mode

Without revalidate, you've recreated the Next.js 14 behaviour on purpose: one evaluation, reused until you redeploy or invalidate it. For on-demand refresh, call revalidatePath("/api/products") from the handler that writes; its reference lists Route Handler paths among what it can invalidate.

Key concept: force-static works by "forcing cookies, headers() and useSearchParams() to return empty values," so a force-static handler that reads a cookie silently sees none. Reserve it for responses that are the same for every caller.

Cache Components: GET handlers prerender like pages

Enable the flag and the segment configs go away: they "are replaced by use cache and cacheLife." A GET handler is now judged by what it touches, like a page (the model from Next.js Cache Components Explained).

// Prerendered at build time: no uncached or runtime data.
export async function GET() {
  return Response.json({ projectName: "Next.js" });
}
Enter fullscreen mode Exit fullscreen mode

The scenario's handler, with its async database query, does not prerender. Prerendering stops on "network requests, database queries, async file system operations, request object properties (…), runtime APIs like cookies(), headers(), connection(), or non-deterministic operations." To cache the query, move it into a helper marked "use cache":

// app/api/products/route.ts
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";

async function getProducts() {
  "use cache";
  cacheLife("hours");
  cacheTag("products");
  return db.query("SELECT id, name, price FROM products");
}

export async function GET() {
  const products = await getProducts();
  return Response.json(products);
}
Enter fullscreen mode Exit fullscreen mode

Two rules from the docs: "use cache cannot be used directly inside a Route Handler body; extract it to a helper function," and "Cached responses revalidate according to cacheLife when a new request arrives." The cacheTag line lets a write elsewhere invalidate it.

Key concept: this is "cache the piece, not the route," the idea Cache Components brought to pages. In a Route Handler, the piece is a helper function instead of a component.

Streaming a response

A Route Handler can stream its body instead of buffering it, which suits server-sent events, long exports, or proxying a slow upstream:

// app/api/ticks/route.ts
import { connection } from "next/server";

export async function GET() {
  await connection(); // under cacheComponents: explicitly request-time
  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 5; i++) {
        controller.enqueue(encoder.encode(`event: tick\ndata: ${i}\n\n`));
        await new Promise((r) => setTimeout(r, 1000));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream" },
  });
}
Enter fullscreen mode Exit fullscreen mode

Under the default model the connection() line is optional; the handler already runs per request. Under cacheComponents it states the intent, since connection() is on the docs' list of calls that stop prerendering.

Key concept: this is unrelated to a page's <Suspense> streaming. Pages stream rendered HTML; a Route Handler streams whatever bytes you enqueue.

Edge cases and gotchas

  • Non-GET methods are never cached, in any version or model. A cached GET doesn't make the POST beside it cached, and there's no config that does.
  • A cached GET doesn't know about writes from elsewhere. Invalidate from the POST handler or webhook that writes. Under cacheComponents, call revalidateTag("products", "max") (the single-argument form is deprecated in 16). Not updateTag: it "can only be called from a Server Action; calling it elsewhere throws." Under the default model, use revalidatePath("/api/products").
  • A synchronous database driver can bring the 14-era bug back under cacheComponents. The caching guide says queries to "embedded databases with synchronous APIs, such as better-sqlite3 or Node.js's built-in node:sqlite" complete during prerendering, and GET handlers follow the same model. If the answer must be live, await connection() before the query.
  • try/catch catches the prerender bail-out. Under cacheComponents, reading uncached or runtime data "bails out of prerendering by throwing," so a try/catch that logs adds noise to the build output (the docs point to experimental.hideLogsAfterAbort: true).
  • Metadata routes kept the old default. "Special Route Handlers like sitemap.ts, opengraph-image.tsx, and icon.tsx, and other metadata files remain static by default unless they use Request-time APIs or dynamic config options."
  • No layouts, no error.tsx. Route Handlers aren't part of the React tree; handle failures with try/catch and explicit status codes.
  • CORS headers are manual. The automatic OPTIONS response sets Allow, not Access-Control-*. Set them on your responses, or for many handlers at once in proxy.ts or next.config headers.
  • export const runtime = "edge" is deprecated. "The Edge Runtime is deprecated. Remove the runtime export from your route files." Node.js is the default, and Cache Components requires it.

Best practices: Route Handler, Server Action, or page?

Reach for a Route Handler when the caller isn't your own App Router UI: a Stripe or GitHub webhook, an OAuth callback, a public API, or a response that isn't HTML, such as a file download, an RSS feed or an SSE stream.

Reach for a Server Action, covered in Server Actions, Mutations & Security, when a form or button in your own UI mutates data. It's also the only place updateTag's read-your-own-writes refresh works.

Reach for a page's own data fetching when nothing outside your app needs the data. A Route Handler that exists only so your own page can fetch() it is usually one hop you don't need.

Whichever you pick, write the caching decision into the file. A reviewer can see a force-static line or a "use cache" helper. A default is something they have to know, and this one has already changed once.

FAQ

Are Next.js Route Handlers cached by default?

No, not since Next.js 15. Under the default model, a GET handler runs on every request until you add export const dynamic = "force-static"; other methods are never cached. Under cacheComponents, a GET handler is prerendered only if it touches no uncached or runtime data.

Why did my GET Route Handler stop being cached after upgrading to Next.js 15?

Because 15 changed the default from static to dynamic; in 14 it was cached only because nothing opted it out. Add export const dynamic = "force-static" (plus revalidate for a refresh window), or a "use cache" helper under cacheComponents.

How do I cache a Route Handler when cacheComponents is enabled?

Move the data access into a helper marked "use cache", give it a cacheLife (and a cacheTag if writes should invalidate it), and call it from the handler. The directive can't go in the handler body, and dynamic, revalidate and fetchCache exports error under the flag.

Can I use cookies() or headers() inside a Route Handler?

Yes. Import them from next/headers and await them; both are async since 15, and synchronous access is removed in 16. Under force-static they return empty values, and under cacheComponents calling them keeps the handler at request time.

Why does my dynamic segment's params need an await?

Next.js 15 made the request-time APIs (params, searchParams, cookies(), headers(), draftMode()) asynchronous, so the framework knows when work has to wait for a request. 15 allowed synchronous access with warnings; 16 removed it.

Cheat sheet

Task Code Notes
Define a handler export async function GET(req: Request) {} One export per method in route.ts; unexported methods get 405
Default for GET (15+, default model) nothing to write Runs on every request; was static in 14
Cache a GET (default model) export const dynamic = "force-static" The documented opt-in since 15
Add a refresh window (default model) export const revalidate = 3600 Seconds; pair with force-static
Refresh on demand (default model) revalidatePath("/api/products") Call from the handler that writes
Cache under cacheComponents "use cache" + cacheLife("hours") in a helper Not in the handler body; segment configs error
Invalidate under cacheComponents cacheTag("products") → revalidateTag("products", "max") updateTag is Server Actions only
Force request time (cacheComponents) await connection() From next/server
Read a dynamic segment const { id } = await ctx.params Promise since 15; sync access removed in 16
Type the context ctx: RouteContext<"/api/products/[id]"> Generated by next dev / build / typegen
Read a query string request.nextUrl.searchParams.get("q") Stops prerendering under cacheComponents
Read a cookie (await cookies()).get("name") From next/headers; empty under force-static
Stream a response new Response(new ReadableStream({ ... })) SSE, exports, proxying
Runtime delete export const runtime = "edge" Edge is deprecated; Node.js is the default

Key takeaways

  • Next.js 15 flipped GET Route Handlers from cached to dynamic by default. Next.js 14 code and tutorials describe the opposite default.
  • In 14 you wrote config to opt out of the cache; in 16 you write it to opt in: force-static under the default model, a "use cache" helper under cacheComponents.
  • Under cacheComponents, GET handlers follow the page rule: prerendered unless they touch uncached or runtime data, and dynamic/revalidate/fetchCache exports error.
  • params is a Promise; 15 tolerated synchronous access, 16 removed it.
  • Non-GET methods are never cached. Choose a Route Handler for callers outside your own UI and a Server Action for your own forms.

The $40 answer and the query-on-every-request are the same missing decision, seen from two versions. In 14 the framework decided "cache it"; in 15 it decided "don't". Either way the handler had a caching policy that nobody wrote down. Put the force-static, revalidate or "use cache" line in the file yourself, and the next change of default can't surprise you.

Which Route Handler in your app is still relying on a default it inherited from 14? Tell me in the comments.

🎮 Try it yourself

▶️ Open the interactive playground →

Runs right in your browser — poke at it and watch the concept react live.

🧠 Test yourself

Think it clicked? Take the 8-question quiz →

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

📚 Read next


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Top comments (0)