NoWaterProgramming

What Breaks When You Enable cacheComponents in Next.js 16: 29 Cases, Measured

Flipping cacheComponents on turns silent build-time data freezes into build errors. We put 29 code patterns through 110 builds on Next.js 16.1.7 and 16.3.5, one pattern per build, and recorded every error verbatim. Twelve patterns fail. Two stop compiling. The pass/fail is identical across both versions - the amount of help you get is not.

28 min read
Share:

Measured against Next.js 16.1.7 and 16.3.5 on 2026-09-19.

Here is a page with no caching instructions anywhere in it, built with cacheComponents off:

export default async function Page() {
  const res = await fetch('http://127.0.0.1:4321/');
  const { call } = await res.json();
  return <p>call {call}</p>;
}
jsx

The fetch target is a local server that counts requests and returns {"call": n}. The build succeeds, prints ○ /probe, and writes this into .next/server/app/probe.html:

<p>call 1</p>

call 1. The first response that server ever gave, now static HTML, permanently, for every visitor until the next deploy. Nothing in the build output mentions it.

Turn cacheComponents on and the identical file is a build error.

That is the entire argument for the flag, and it is the reason the migration is worth doing rather than deferring. What it costs you is a list of things that stop building, and that list is what this article measures: 29 code patterns, one per build, 110 builds, across two Next.js versions.

The setup

The discipline matters more than the app, so the app is as small as it can be: a root layout, a home page, and one route under test.

  • next.config.mjs reads cacheComponents: process.env.CC === '1'. Every probe is built twice, once with CC=0 and once with CC=1. The two builds differ in exactly one config value and nothing else, so any difference between them is the flag and cannot be anything else.
  • One probe per build. The harness copies a single file into app/probe/ and builds. A page that does four questionable things at once tells you nothing about which of the four the error belongs to.
  • .next is deleted between every build.
  • Two Next.js versions with separate node_modules: 16.1.7, and 16.3.5, which was latest on the day of the test and is the version the current docs document.
  • For the fetch probes, a ten-line HTTP server on 127.0.0.1:4321 returning an incrementing counter. A public API would have made the results depend on someone else's cache headers, and a counter makes a frozen prerender visible instead of merely inferred.
  • Two further apps are built once with the flag on and served with next start, so cache hits can be counted. Every cached function calls console.log('[EXEC name]'), which turns "did this run?" into a grep.

Read the results table as the build prints it: static, partial prerender, ƒ dynamic.

The matrix

○  (Static)             prerendered as static content
◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed content
ƒ  (Dynamic)            server-rendered on demand

Pass and fail were identical on 16.1.7 and 16.3.5 for all 25 probes that both versions can run, so one table covers both. The Revalidate / Expire pair is the two extra columns the build table grows when a route has a cache lifetime.

what the code doesflag offflag on
await cookies() at the top of the pagebuilds, ƒerror
await searchParams at the top of the pagebuilds, ƒerror
uncached async IO at the top of the pagebuilds, error
fetch(url) at the top of the pagebuilds, error
fetch(url, { cache: 'force-cache' })builds, builds,
the same IO inside <Suspense>builds, builds,
cookies() inside <Suspense>builds, ƒbuilds,
await connection() then IO, no <Suspense>builds, ƒerror
'use cache', no cacheLifeerrorbuilds, 15m / 1y
'use cache' + cacheLife('hours')errorbuilds, 1h / 1d
'use cache' on the page component itselferrorbuilds, 15m / 1y
'use cache' wrapping an uncached inner callerrorbuilds, 15m / 1y
fetch(url) inside 'use cache'errorbuilds, 1h / 1d
'use cache: remote' + cacheLife('hours')errorbuilds, 1h / 1d
cookies() inside 'use cache'errorerror
a function argument, called inside 'use cache'errorerror
'use cache' + cacheLife('seconds'), no <Suspense>errorerror
the same, inside <Suspense>errorbuilds,
cacheLife('seconds') nested in a 'use cache' with no cacheLifeerrorerror
Math.random() inside 'use cache'errorbuilds, 15m / 1y
unstable_cache(fn, ['k'], { revalidate: 60 })builds, 1m / 1ybuilds, 1m / 1y
export const revalidate = 60builds, 1m / 1ystops compiling
export const dynamic = 'force-dynamic'builds, ƒstops compiling
route handler GET with async IObuilds, ƒbuilds, ƒ
generateMetadata awaits searchParams, body staticbuilds, ƒerror

Every error in the flag-off column is the same one - 'use cache' present without the flag - and it names its own fix:

To use "use cache", please enable the feature flag `cacheComponents` in your
Next.js config.

Twelve patterns fail with the flag on. Ten fail during prerendering, two fail before any page renders at all.

Four more patterns only exist on 16.3, so they get their own rows:

what the code doesflag offflag on (16.3.5)
export const instant = false + uncached IO at the top of the pagestops compiling - the export needs the flagbuilds, ƒ
Date.now() in the page bodybuilds, - value baked into the HTMLerror
export const instant = false and Date.now()stops compilingerror, the same one
'use cache: private' + cacheLife({ stale: 60 })stops compiling - the directive needs the flagerror - a private cache is request-scoped, so it needs a <Suspense> boundary like any other dynamic read

instant = false is the per-route escape hatch. It marks a segment as allowed to block, without forcing the route to be dynamic,1 which is what lets you get a large app building before you have converted any of it. The last row is the one to remember if you reach for 'use cache: private' as a way around the cookies() restriction: it does not exempt the route from the boundary rule.

One error covers seven of them

On 16.1.7, these seven all produce the same string, byte for byte: cookies() at the top level, await searchParams, any uncached async IO, a plain fetch, await connection(), a cacheLife('seconds') scope outside <Suspense>, and a short-lived cache nested inside a 'use cache' with no cacheLife.

Error: Route "/probe": Uncached data was accessed outside of <Suspense>. This
delays the entire page from rendering, resulting in a slow user experience.
    at body (<anonymous>)
    at html (<anonymous>)

Two frames of <anonymous>. No file, no function, no call.

On 16.3.5 the same seven probes produce this instead:

Error: Route "/probe": Next.js encountered uncached or runtime data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or
`connection()` accessed outside of `<Suspense>` prevents the route from being
prerendered, blocking the page load and leading to a slower user experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>` around the data access
  - [cache] For uncached data (`fetch`, database calls): cache the access with `"use cache"` (does not apply to `connection()`)
  - [block] Set `export const instant = false` to allow a blocking route

Six causes named, three fixes labelled, and the [block] option does not exist in 16.1 at all. The stack is still at body (<anonymous>), but the message no longer needs it.

This is the practical headline of the whole exercise. The rules did not change between 16.1 and 16.3; the amount of help did. If you are on 16.1.x and planning this migration, upgrade Next first. You will be reading these messages for a week.

Two more differences, both improvements:

  • The nested short-lived cache gets its own error on 16.3.5, naming the exact fix: "A use cache with short expire (under 5 minutes) is nested inside another use cache that has no explicit cacheLife ... Add cacheLife() to the outer." On 16.1.7 it is indistinguishable from six other problems.
  • generateMetadata gets a shorter, clearer message: "Next.js encountered uncached or runtime data in generateMetadata()."

Two exports that stop compiling

These are the only two failures that are not prerender errors. They happen in Turbopack, before a single page renders, and they name the file and the line:

./app/probe/page.jsx:1:14
> 1 | export const dynamic = 'force-dynamic';
    |              ^^^^^^^
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`.
Please remove it.

The same for revalidate. This is documented - the migration guide says segments that still export dynamic, revalidate or fetchCache "will error"1 - but the word "error" undersells what it feels like. dynamic = 'force-dynamic' is the escape hatch every Next.js codebase reaches for when a page must not be prerendered, and under this flag it is not deprecated, not ignored, not warned about. It does not compile.

The replacements are not like-for-like:

  • dynamic = 'force-dynamic' is deleted, because every page is dynamic by default now.1
  • dynamic = 'force-static' becomes 'use cache' with a long cacheLife, as close to the data access as you can put it.1
  • revalidate = 3600 becomes cacheLife('hours') inside a 'use cache' scope.1
  • fetchCache is deleted; fetches inside a cached scope are cached anyway.1

The important part is that the first one moves a decision from the route to the data. You no longer say "this page is dynamic"; you say "this call is cached" and the page's shape follows from that. Most of the work in a real migration is finding out which calls those are.

What does not break

Four results that were not the expected answer, and each one saves time:

fetch(url, { cache: 'force-cache' }) builds fine under the flag. The same fetch without the option does not. One option, in the call you already have, and the route stays . This is the narrow answer to the question people keep asking on the Next.js repo - how to get back the implicit caching the old model gave them2 - and it is worth knowing before you refactor anything. The migration guide's recommended shape is to move the fetch into a 'use cache' function and replace next: { revalidate, tags } with cacheLife and cacheTag1 - which is better, because it also covers the database call next to the fetch. But if you need a build green tonight, force-cache is a one-line change.

unstable_cache still works, and says nothing. The docs say to replace it with 'use cache'.1 The build does not agree that it is urgent: unstable_cache(fn, ['k'], { revalidate: 60 }) compiles under the flag, marks the route , and prints 1m / 1y in the table like any other cached route. No deprecation, no warning, no nudge. There is a real reason to migrate anyway - unstable_cache persists across deployments and serverless instances while 'use cache' defaults to in-memory storage that does not3 - but it is not a blocker, and nothing in your build output will tell you it is there.

Route handlers are untouched. A GET handler doing uncached async IO builds identically with the flag off and on, and stays ƒ. Handlers are not subject to the shell-prerendering rule that pages are, which also means the route handler behaviour we measured separately still holds.

Math.random() inside 'use cache' compiles without complaint. Which is where it gets interesting.

The guard stops at the cache boundary

On 16.3.5, Date.now() in a page body is a build error - and it is the best error in the entire exercise, the only one that pointed at the line:

Error: Route "/probe": Next.js encountered the unstable value `Date.now()` while prerendering.
This value can change between renders, so it must be either prerendered or computed later.
Ways to fix this:
  - [dynamic] Render at request time by adding a dynamic data access (e.g. `await connection()`) before this call
  - [cache] Prerender and cache the value with `"use cache"`
  - [client] Render the value on the client with `"use client"`
  - [measure] If the value is for telemetry, use a timing API such as `performance.now()`
    at c (app/probe/page.jsx:2:19)
  1 | export default async function Page() {
> 2 |   return <p>{Date.now()}</p>;
    |                   ^

Move that same Date.now() inside a 'use cache' function and the build is clean. So is Math.random(). Served from next start, three requests each:

routerequest 1request 2request 3
Math.random() in 'use cache'0.430115186784718830.430115186784718830.43011518678471883
Date.now() in 'use cache'178976969543517897696954351789769695435

Both frozen at build time, for an hour, silently. This is correct - a cached function is defined to produce the same output for the same inputs3 - and it is also the second option in the error message above. Take the [cache] fix for a timestamp and you have not made the timestamp work; you have made it a constant with a one-hour lifetime.

The rule underneath: the unstable-value guard checks the prerender, not your intent, and a cache entry is a legitimate place for a prerendered value to live. If the value has to move, it is [dynamic] or [client], not [cache].

What 'use cache' actually caches

Four routes, flag on, built once and served with next start. Execution counts come from the [EXEC] log lines.

routeshaperuns during next buildruns across 12 requests
'use cache' + cacheLife('hours')10
the same + cacheTag('lab')10
'use cache' + cacheLife('seconds') in <Suspense>11
uncached IO in <Suspense>23

The two routes never ran again on the server. They were answered entirely out of the build, and the timestamps they rendered were the build's. The uncached route ran once per request, as it should, and twice during the build - the prerender attempts it, discovers it cannot finish, and renders the shell.

The build table's two extra columns are worth reading, because they are the only place the lifetime is visible without opening code:

cacheLife callRevalidateExpire
none15m1y
'hours'1h1d
unstable_cache({ revalidate: 60 })1m1y

Identical on both versions. Note the first row against the documentation: the default profile is documented as revalidate 15 minutes and expire never.4 "Never" is printed as 1y.

The header, which is where the money is

Ask the running server for the two shapes and the difference is not subtle.

A route with cacheLife('hours'):

x-nextjs-cache: HIT
x-nextjs-prerender: 1
x-nextjs-stale-time: 300
Cache-Control: s-maxage=3600, stale-while-revalidate=82800
ETag: "11ds2rt3axz3me"
Content-Length: 4694

A route in the same app:

x-nextjs-prerender: 1
x-nextjs-postponed: 1
x-nextjs-stale-time: 300
Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate

The first three numbers in the response are cacheLife arithmetic, and the mapping is exact:

  • s-maxage=3600 is the profile's revalidate, in seconds - how long a shared cache may serve the response as fresh.5
  • stale-while-revalidate=82800 is expire minus revalidate: 86,400 - 3,600. It is how much longer a shared cache may serve the stale copy while fetching a new one in the background.5
  • x-nextjs-stale-time: 300 is the profile's stale, five minutes, which is the client router's copy rather than the CDN's.4 It is the same header the App Router's prefetching reads.

Now the response. private, no-cache, no-store, max-age=0, must-revalidate. No ETag. No Content-Length. A partially prerendered route is not shared-cacheable at all - every request reaches your origin, every time, and the static shell you gained is a latency win on the client rather than a cost win on the server.

That deserves saying plainly, because <Suspense> is the fix the error message offers first: wrapping a page in <Suspense> to satisfy the flag takes that route off your CDN. For a page that genuinely needs per-request data it is the right trade - you were never going to cache it anyway. For a page where one corner reads a cookie it might not be, and the alternative is to read the cookie somewhere else, pass the value in as an argument,3 and keep the route .

Invalidation

cacheTag plus revalidateTag from a route handler, measured on the same app:

stepvalue renderedtimes the cached function ran
after the buildthe build's timestamp0
GET /api/revalidate{"revalidated":true}a new timestamp1
request it againthe same new timestamp1

Exactly one re-execution, and the untagged route next to it never moved from its build-time value. That is the whole mechanism working as advertised.

One thing the build says and nobody repeats:

"revalidateTag" without the second argument is now deprecated, add second
argument of "max" or use "updateTag".

The migration guide is stronger than the build here: it says a cache profile is "required" as the second argument, and that updateTag is for read-your-own-writes from a Server Action while revalidateTag is for stale-while-revalidate and works in route handlers.1 On 16.3.5 the single-argument call still builds. It only warns. Add the argument anyway - a required-in-the-docs, deprecated-in-the-build API is the shape of something that becomes an error in a minor release.

Two flags that decide how your week goes

next build names one broken route and stops. Three independently broken routes in the tree, one build:

Error: Route "/multi/c": Uncached data was accessed outside of <Suspense>. ...
Export encountered an error on /multi/c/page: /multi/c, exiting the build.

/multi/a and /multi/b are not mentioned. On a real app that is fix, rebuild, meet the next one, for as many rounds as you have routes.

Unless you ask for the list. The same tree with next build --debug-prerender:

  ⨯ prerenderEarlyExit (disabled by `--debug-prerender`)
  ✓ serverSourceMaps (enabled by `--debug-prerender`)
  ⨯ turbopackMinify (disabled by `--debug-prerender`)
...
Error: Route "/multi/c": ...
Error: Route "/multi/a": ...
Error: Route "/multi/b": ...
> Export encountered errors on following paths:
	/multi/a/page: /multi/a
	/multi/b/page: /multi/b
	/multi/c/page: /multi/c

All three, plus a combined list at the end. Its own banner says why: it disables prerenderEarlyExit. Run it once at the start of the migration and you have your work queue instead of your next task. The one thing it did not deliver is better stack traces - despite switching serverSourceMaps on, the data errors still ended at at body (<anonymous>).

next dev will not stop you. Flag on, the offending page in place:

GET /probe 200 in 202ms

HTTP 200, correct body, nothing in the response. The error goes to the terminal, and on 16.1.7 the wording there is different from the build's - it is the one place that names connection() as a cause. So it is possible to develop a feature all day, commit it, and meet the whole error list for the first time in CI. Build locally before you push, at least once, on any branch that touches data fetching.

What to do, in order

  1. Upgrade Next.js before you touch the flag. The rules are the same on 16.1 and 16.3; the error messages are not close. Migrating on 16.1 means reading one sentence seven different ways.
  2. Delete the route segment configs first. dynamic, revalidate, fetchCache, dynamicParams, experimental_ppr, runtime = 'edge'.1 They are compile errors, so nothing else can be measured until they are gone.
  3. Run next build --debug-prerender once and keep the output. That list is the migration.
  4. For each route, pick the fix by what the data is, not by what silences the error. Cacheable external data → 'use cache' as close to the call as possible, with an explicit cacheLife. Per-request data → push it down to the smallest component and wrap that in <Suspense>, knowing the route leaves the CDN. Something that needs the request but sits in a cached scope → read it outside and pass the value in as an argument.3
  5. Set cacheLife explicitly in every 'use cache' scope. The default profile is 15 minutes, which is a decision whether or not you made it, and an inner scope with a short lifetime can drag an outer one down when the outer has no explicit profile.4
  6. Read the Revalidate / Expire columns after every build. They are the cheapest review of your caching you will ever get.
  7. On 16.3, use instant = false to defer a route, not to fix one. It lets the app build with the route still blocking,1 which is how you convert an app gradually instead of in one commit. It does not clear synchronous-value errors - we confirmed that: instant = false with Date.now() in the body still fails.
  8. Do not let [cache] be the reflex fix for a timestamp. It compiles, and it freezes.

Where this is weak

  • A minimal app, not a real one. Every probe is a handful of lines with one thing wrong. That is what makes each error attributable, and it is also why this article cannot tell you how many routes in your app will fail, or how a layout, a parallel route or a client hook reading the pathname behaves in the middle of a real tree. The documented list of client hooks that need a boundary under this flag1 is longer than anything measured here.
  • Two versions, one day. 16.1.7 and 16.3.5, both on macOS arm64 with Node 24.18.0, Turbopack. The messages changed noticeably across two minor releases, which is the best possible evidence that they will change again.
  • next start on Node, not a serverless deployment. This matters more than usual: with the default in-memory handler, 'use cache' entries persist across requests when self-hosted and typically do not on serverless, where each request can be a different instance.3 Every execution count in this article is a self-hosted number. On Vercel or a Workers adapter, expect the cached function to run more often than 0 times in 12 requests.
  • Build-time caching only, mostly. The revalidation and tag measurements ran over seconds, not days, and the expire path - what happens after a long quiet period - was not exercised at all.
  • No cacheHandlers, no 'use cache: remote' at runtime. 'use cache: remote' was probed for whether it builds, not for what it does. A remote handler changes the persistence story completely and deserves its own measurement.
  • The Content-Length difference is a shape, not a benchmark. We compared cache headers and execution counts, not latency. Nothing here says a route is slower for a user; it says it costs your origin a render.

FAQ

What does cacheComponents do in Next.js 16?

It turns off implicit caching and makes you opt in. Data fetching becomes dynamic by default, 'use cache' marks what should be cached, and Next.js prerenders a static HTML shell for each route and streams the dynamic parts in.6 It also makes Partial Prerendering the App Router default, which is why experimental.ppr and experimental_ppr were removed, and it replaces the older experimental.dynamicIO and experimental.useCache flags.6 It requires the Node.js runtime.6

How do I fix "Uncached data was accessed outside of <Suspense>"?

There are three fixes and the right one depends on the data. If it is external data you can cache - a fetch, a database query - move it into a function marked 'use cache' with an explicit cacheLife. If it is per-request data - cookies(), headers(), searchParams, params, connection() - move the access into a child component and wrap that child in <Suspense>. On 16.3 and later you can also set export const instant = false on the segment to allow the route to block.1 Note that seven different causes produce this identical message on 16.1.7, and its stack trace names no file, so the first step is often next build --debug-prerender to see every affected route at once.

Why does export const dynamic = 'force-dynamic' fail to compile with cacheComponents?

Because route segment configs are replaced by 'use cache' and cacheLife under this flag, and Next.js rejects them rather than ignoring them: "Route segment config "dynamic" is not compatible with nextConfig.cacheComponents. Please remove it."1 It is a Turbopack compile error with a file and line, not a prerender error. force-dynamic is simply deleted - every page is dynamic by default now - and revalidate becomes a cacheLife call inside a cached scope.1 The same applies to fetchCache, dynamicParams and runtime = 'edge'.1

Does connection() satisfy cacheComponents?

No. await connection() before an uncached call still fails the prerender with the same error as the call on its own, because the requirement is a <Suspense> boundary, not a dynamic marker. The 16.3.5 message is explicit that its [cache] fix "does not apply to connection()". connection() is how you tell Next.js that something must run at request time; the boundary is how you let the rest of the page prerender anyway, and you need both.

Do I have to replace unstable_cache when I enable cacheComponents?

Not to get the build green. unstable_cache compiles under the flag with no warning at all, marks its route static, and appears in the build table with its revalidate time like any other cached route. The docs do say to replace it with 'use cache'.1 The real reason to is persistence: unstable_cache and the fetch Data Cache survive a deployment and are shared across serverless instances, while 'use cache' defaults to in-memory storage that is scoped to one instance and one deploy.3 That is a different trade-off from the one the build is asking you about.

What is the default cacheLife if I don't call cacheLife?

The default profile: stale 5 minutes, revalidate 15 minutes, and an expire the docs describe as never.4 In the build output that never prints as 1y. Both 16.1.7 and 16.3.5 show 15m / 1y for a 'use cache' scope with no cacheLife call. Set one explicitly anyway - without it, a nested cache with a shorter lifetime can pull the outer scope's lifetime down, and a scope with expire under five minutes is excluded from the prerender entirely.4

Does Math.random() or Date.now() inside "use cache" cause a build error?

No, and that is worth knowing before you use it as a fix. Date.now() in a page body is a build error on 16.3.5 with a message naming four possible fixes, one of which is caching it. Do that and the build passes - and the value is then frozen at build time and served identically to every request for the whole cache lifetime, because a cached function returns the same output for the same inputs.3 We measured three requests each: the same random number and the same timestamp every time. For a value that must actually change, the fix is connection() plus a <Suspense> boundary, or rendering it on the client.

Is a partially prerendered route still cached by a CDN?

No. A route returns Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate with x-nextjs-postponed: 1, no ETag and no Content-Length, so every request goes to the origin. A fully static route in the same app returns s-maxage and stale-while-revalidate derived from its cacheLife and x-nextjs-cache: HIT. The practical consequence is that adding a <Suspense> boundary to satisfy a build error also moves that route off shared caching, so it is worth checking whether the data inside it could be passed in as an argument to a cached scope instead.

Sources

Checked 2026-09-19. The published documentation at that date is version 16.3.5; where it disagrees with the 16.1.7 lab, the article says which version was measured.

Sources

  1. Next.js docs: Migrating to Cache Components - that after enabling the flag "route segments that still export dynamic, revalidate, or fetchCache will error"; that dynamic = 'force-dynamic' is "not needed" because all pages are dynamic by default and dynamic = 'force-static' becomes use cache with a long cacheLife; that revalidate is replaced by cacheLife and fetchCache is unnecessary; that a fetch's cache and next: { revalidate, tags } options move into a use cache function as cacheLife and cacheTag; that unstable_cache is "replaced with use cache"; that reading cookies(), headers() or searchParams outside a <Suspense> boundary raises a validation insight and the fix is to move the access into a wrapped child; that instant = false "marks a segment as allowed to block", "does not force the route to be dynamic" and "does not clear synchronous IO build errors"; that dynamicParams "is not supported" and fails the build with the same route-segment-config message; that runtime = 'edge' is not supported; that revalidateTag takes a cache profile as a "required" second argument and works in Route Handlers while updateTag can only be called from a Server Action; and that useSearchParams, usePathname, useParams, useSelectedLayoutSegment and useSelectedLayoutSegments need a <Suspense> boundary when the route's pathname is not fully known.

  2. vercel/next.js discussion #89375, "How to properly implement use cache with Next.js 16 App Router - replacing old implicit caching?" - that developers migrating to Next.js 16 are actively asking how to replace the implicit caching the previous model gave them, which is the question the fetch and unstable_cache rows in the matrix above answer directly.

  3. Next.js docs: use cache - that "a cached function produces the same output for the same inputs" and every later call with the same inputs reuses the stored output; that the cache key is built from the build ID, a hash of the function's location and signature, and its serializable arguments, with outer-scope variables captured as arguments; that arguments and return values must be serializable and functions are an unsupported argument type "except as pass-through", accepted only "as long as you don't introspect them"; that cached functions cannot access cookies(), headers() or searchParams, that the restriction follows the call stack, and that the pattern is to read them outside the cached scope and pass the values as arguments; that outputs are stored in memory by default, that entries "typically don't persist across requests" on serverless while they do when self-hosted, and that no caching directive carries over to a new deploy because the cache key includes the build ID; and that unstable_cache or the fetch cache is the recommendation "for data that needs to persist across deploys".

  4. Next.js docs: cacheLife - the preset profile table, including default at stale 5 minutes, revalidate 15 minutes and expire "never", and hours at 5 minutes, 1 hour and 1 day; that omitting cacheLife applies the default profile; that stale is the client-router lifetime, sent in the x-nextjs-stale-time response header, with a 30-second minimum enforced; that revalidate triggers a background refresh while expire forces a synchronous regeneration; that a scope with revalidate of 0 or expire under 5 minutes is "excluded from prerenders, becoming a dynamic hole resolved at request time" and that among the presets only seconds crosses that threshold; and that without an explicit cacheLife on an outer scope, an inner cache with a shorter lifetime reduces the outer scope's lifetime.

  5. MDN: Cache-Control - that s-maxage overrides max-age for shared caches and sets how long the response stays fresh in them; that stale-while-revalidate=N permits a cache to serve a stale response for up to N seconds after it becomes stale while it revalidates in the background; and that no-store, no-cache, must-revalidate and private together stop a shared cache from storing or reusing a response.

  6. Next.js docs: cacheComponents - that Cache Components enables component- and function-level caching through use cache, that "data fetching is dynamic by default" and you choose what to cache, that Next.js "prerenders a static HTML shell that is served immediately while dynamic content streams in when ready", that it requires the Node.js runtime, that it implements Partial Prerendering as the App Router default so experimental.ppr and experimental_ppr "have been removed", that it replaces experimental.useCache and experimental.dynamicIO, and that it was introduced in 16.0.0.

Related Posts

19 min read
We measured client-side navigation on a deployed Next.js 16.1 site: a soft navigation transfers about half the bytes of a full page load, and a <Link> prefetch transfers the same payload as the navigation it prepares - the whole route, once per link in the viewport, in production. Then we read the useRouter return type out of the installed package: six methods, and no router.events, router.query, router.pathname, router.isReady, router.beforePopState or shallow routing.
By NoWaterProgramming Team
15 min read
We seeded thirteen failure modes into Next.js 16.1 route handlers and recorded the status, headers and body the client got back, under next dev and under a production build. An uncaught throw is an empty 500 with no Content-Type. Throwing a Response does not set its status. A stream that fails after its first byte is a truncated 200. Dev and production returned the same thing every time.
By NoWaterProgramming Team
18 min read
We put ten defects into real route handlers and ran four testing methods at them: a direct call, next-test-api-route-handler, next start, and the Cloudflare Workers build we deploy. Two methods pass a handler that is broken, and one fails a handler that is fine.
Next.jstesting
By NoWaterProgramming Team