Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16
Next.js 15 made GET Route Handlers dynamic by default. What that changes for code written for 14, how to cache one on purpose in 16, plus a cheat sheet.

- 1Next.js Cache Components Explained (with Cheat Sheet)14 min
- 2Next.js Server Actions: Mutations & Security (Cheat Sheet)15 min
- 3Next.js Parallel & Intercepting Routes: Modals Done Right14 min
- 4Next.js proxy.ts Explained (with Cheat Sheet)12 min
- 5Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16you are here
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.
By the end of this article you'll be able to:
- Explain what changed for
GETRoute Handlers between Next.js 14 and 15, and why a handler migrated from 14 now runs on every request - Cache a
GEThandler on purpose in Next.js 16:force-staticandrevalidateunder the default model, a"use cache"helper undercacheComponents - Predict when a
GEThandler is prerendered at build time undercacheComponents, and what stops it - Read dynamic segments correctly now that
paramsis 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
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.
- The problem: Next.js Route Handlers changed their default in 15
- The mental model
- Caching Next.js Route Handlers on purpose in 16
- Edge cases and gotchas
- Best practices: Route Handler, Server Action, or page?
- FAQ
- Cheat sheet
- Key takeaways
Here's the handler from the scenario, written the way most people write their first one:
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.
- 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 aGET. Once you enablecacheComponents, it breaks: "route segments that still exportdynamic,revalidate, orfetchCachewill 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.
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.
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.
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.
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.
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.
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:
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 .
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."
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.
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).
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":
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.
A Route Handler can stream its body instead of buffering it, which suits server-sent events, long exports, or proxying a slow upstream:
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.
- Non-
GETmethods are never cached, in any version or model. A cachedGETdoesn't make thePOSTbeside it cached, and there's no config that does. - A cached
GETdoesn't know about writes from elsewhere. Invalidate from thePOSThandler or webhook that writes. UndercacheComponents, callrevalidateTag("products", "max")(the single-argument form is deprecated in 16). NotupdateTag: it "can only be called from a Server Action; calling it elsewhere throws." Under the default model, userevalidatePath("/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 asbetter-sqlite3or Node.js's built-innode:sqlite" complete during prerendering, andGEThandlers follow the same model. If the answer must be live,await connection()before the query. try/catchcatches the prerender bail-out. UndercacheComponents, reading uncached or runtime data "bails out of prerendering by throwing," so atry/catchthat logs adds noise to the build output (the docs point toexperimental.hideLogsAfterAbort: true).- Metadata routes kept the old default. "Special Route Handlers like
sitemap.ts,opengraph-image.tsx, andicon.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 withtry/catchand explicit status codes. - CORS headers are manual. The automatic
OPTIONSresponse setsAllow, notAccess-Control-*. Set them on your responses, or for many handlers at once inproxy.tsornext.configheaders. export const runtime = "edge"is deprecated. "The Edge Runtime is deprecated. Remove theruntimeexport from your route files." Node.js is the default, and Cache Components requires it.
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.
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.
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.
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.
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.
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.
| 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 |
- Next.js 15 flipped
GETRoute 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-staticunder the default model, a"use cache"helper undercacheComponents. - Under
cacheComponents,GEThandlers follow the page rule: prerendered unless they touch uncached or runtime data, anddynamic/revalidate/fetchCacheexports error. paramsis a Promise; 15 tolerated synchronous access, 16 removed it.- Non-
GETmethods 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.
Runs right in your browser — poke at it and watch the concept react live.
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
- You Don't Need a WebSocket for That Live Feed
- Your Fetch Already Streams. You're Buffering It Anyway.
- Next.js proxy.ts Explained (with Cheat Sheet)
🚀 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:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Keep reading
One post a day, in your inbox
Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.
0 comments
Sign in to join the discussion, like comments, and save articles for later.