Skip to content
corpus.web

The Cache Components model

Next 16 caches nothing unless you opt in, and that inversion is why most existing Next.js advice is now actively wrong

Baseline Next.js 16.3
Kind Concept
Wave 1
24 blocks verified
View source
Lead

Lead with this. Next.js used to cache aggressively and let you opt out. It now caches nothing and makes you opt in. That inversion is the reason most Next.js advice you will find — in tutorials, in answers, in model completions — is not merely dated but actively wrong: the same code now means the opposite thing, and the mismatch is invisible in next dev.

The model that replaced it is small. Every piece of data answers one of four questions — cache it, stream it, block on it, or push it to the client — and Next.js validates in development that you answered.

This is the thesis article of this repository. Every "Then vs now" section elsewhere cites it.


What it is

cacheComponents: true is one decision that changes one default:

demos/next-lab/next.config.tsextracted
ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,}
export default nextConfig

After that line, nothing is cached unless a 'use cache' directive says so. Data access is dynamic by default. Next.js prerenders whatever static shell it can, serves it immediately, and streams the rest.

Why the default was inverted

The old model was not one cache. It was four, layered, each with a different opt-out:

LayerWhat it cachedHow you opted out
Request memoizationDuplicate fetches in one render passNothing — automatic, per-request
Data Cachefetch responses across requests and deployscache: 'no-store', next: { revalidate: 0 }
Full Route CacheRendered HTML + RSC payload per routeexport const dynamic = 'force-dynamic', or accidentally, by reading cookies()
Router CachePayloads on the client, per navigationstaleTimes config, router.refresh()

Nothing in that table is unreasonable in isolation. Together they produced a specific, well-documented failure mode: you could not look at a component and know whether it was cached. The answer depended on layers you hadn't written, in files you weren't reading, keyed by rules you hadn't opted into. "Why is my page serving stale data" and "why is my page not caching at all" were the same question with different luck, and answering it meant reading framework documentation rather than reading your own code.

The fix was not a better set of defaults. It was removing the implicitness: one primitive, applied where you can see it, with the caching decision visible at the call site.

The four answers

Every piece of data in a route gets exactly one:

AnswerMechanismChoose it when
Cache it'use cache' + cacheLife + cacheTagThe value is the same across many requests, and you can name the write that invalidates it.
Stream it<Suspense>The value differs per request, and the user has something else to look at meanwhile.
Block on itexport const instant = falseA shell would be a lie — the page is the data.
Push it to the client'use client' + fetch after hydrationThe value is device-local, or changes on interaction.

The useful discipline is the tie-breaker: if you cannot name the write that invalidates it, the answer is "stream it," not "cache it." Most stale-data bugs are a "cache it" chosen without an invalidation story.


How it works under the hood

What the flag actually turns on

cacheComponents: true is not a caching toggle. It switches on the rendering architecture described in thinking-in-the-app-router:

  • The prerender/request split. Partial Prerendering stopped being an experiment and became the way routes render. experimental.ppr and the experimental_ppr segment config were removed, because there is nothing left to opt into.
  • Abort-based dynamism. Runtime APIs abort the prerender at their call site instead of setting a route-level flag.
  • The cache directives. 'use cache', 'use cache: remote', and the experimental 'use cache: private' become available, along with cacheLife and cacheTag.
  • Validation. Development checks whether each route can render instantly and reports what is blocking it.
  • A runtime constraint. Cache Components requires the Node.js runtime; runtime = 'edge' is deprecated and incompatible.

It also retires a family of exports. dynamic, revalidate, and fetchCache on a route segment now error rather than being ignored — which is the right call, because a silently-ignored caching directive is exactly the class of bug the model exists to remove.

The decision is not binary — lifetime moves it

Here is the part that is genuinely non-obvious, and the reason "cache it" and "stream it" are ends of a spectrum rather than two boxes.

A cache profile has three clocks, and they are not the same clock:

  • stale — how long the client router serves cached content without checking the server.
  • revalidate — how long before the server regenerates in the background, serving the old value meanwhile.
  • expire — how long before the server must regenerate synchronously, making the next request wait.

The default profile is stale 5 minutes, revalidate 15 minutes, expire never.

Now the mechanism: a short enough lifetime disqualifies content from the prerender entirely.

ConditionEffect
revalidate: 0, or expire under 5 minutesExcluded from prerenders — becomes a dynamic hole resolved at request time
stale under 30 secondsExcluded from prerenders, because a prefetch would expire before the user could click
stale between 30 seconds and 5 minutesPrerendered, but excluded from the route's App Shell

Of the preset profiles, only seconds crosses any threshold — its one-minute expire keeps it out of prerenders.

So cacheLife('seconds') does not mean "cached, briefly." It means "streamed, with a server-side cache in front of it." The answer you thought you picked was "cache it"; the answer the framework acted on was "stream it." That is not a gotcha — it is the framework refusing to prerender something that would be stale before anyone saw it — but it will surprise you once, and it explains build output that otherwise looks wrong.

A related enforcement, which exists because the surprise would otherwise be silent: when a short-lived cached function is nested inside a 'use cache' scope that has no explicit cacheLife, the outer scope's lifetime would silently be dragged down to the inner one's. Next.js throws during prerendering instead. The nested cache can easily be somewhere you aren't looking — an imported module, or a third-party dependency — so the error is often the first you hear of it. The fix is to state the outer lifetime explicitly, which is why "call cacheLife in every 'use cache' scope" is a real convention and not ceremony.

Lifetimes also compose asymmetrically. An explicit outer cacheLife always wins, longer or shorter. Without one, an inner cache with a shorter lifetime can pull the outer scope down, while an inner cache with a longer one cannot push it up past the default.

Cache keys are derived, not written

The old model made you write keys: unstable_cache(fn, ['user', id], opts). The new one derives them — from the build ID and the serialized arguments. Anything read from ambient state at execution time cannot be in the key — and the framework can only stop you when that ambient state is a Next API it recognizes.

Two consequences, and they pull in opposite directions.

The good one: a whole class of stale-key bugs disappears. You cannot forget to add an argument to the key array, because there is no key array.

The dangerous one: anything that is not an argument is not in the key. A cached function has no idea who is asking.

Next.js guards the obvious version of this. Reading cookies() inside a cached scope — as in the leak-a demo — throws at request time:

demos/next-lab/lib/billing-leak-a.tsgetDashboardLeakAextracted
ts
import { cacheLife } from 'next/cache'import { cookies } from 'next/headers'import { db } from './db'
export async function getDashboardLeakA() {  'use cache'  cacheLife('minutes')  const uid = (await cookies()).get('uid')?.value ?? 'anonymous'  return db.usage.forUser(uid)}
demos/next-lab/observations/leak-a-runtime-error.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: next start; curl -H "Cookie: uid=user-alice" /leak-aHTTP 200 (error UI in body)Route /leak-a used cookies() inside "use cache". Accessing Dynamic data sources inside a cache scope is not supported. This is a bug in your application.digest: 3264027773@E831

Note when it throws: at request time, not at build. Whether a dynamic API is reachable through a call chain isn't statically decidable, so the check has to be dynamic — which means a path only exercised for signed-in users won't fail until a signed-in user hits it. A build that goes green is not evidence that no cached scope reads the request.

The vectors that do leak evade the key rather than the guard.

Ambient module state. No Next API is touched, so nothing fires — and the value is read at execution time, so it cannot be in the key:

demos/next-lab/lib/billing-leak-b.tsextracted
ts
import { cacheLife } from 'next/cache'import { db } from './db'
let lastSeenUid = 'anonymous' // set by an uncached caller before the call
export function rememberUid(uid: string) {  lastSeenUid = uid}
export async function getDashboardLeakB() {  'use cache'  cacheLife('minutes')  return db.usage.forUser(lastSeenUid)}

Alice's dashboard is stored. Bob's request matches the same key and receives it.

A key that doesn't discriminate. Here the argument is in the key. It is simply the same argument for every signed-in user:

demos/next-lab/lib/billing-leak-c.tsgetDashboardLeakCextracted
ts
import { cacheLife } from 'next/cache'import { db } from './db'
// The argument is in the key — but every signed-in user passes the same one.export async function getDashboardLeakC(scope: 'signed-in' | 'anonymous') {  'use cache'  cacheLife('minutes')  const uid = scope === 'signed-in' ? 'user-alice' : 'anonymous'  return db.usage.forUser(uid)}

The guard is satisfied. The key is honest. The result is still wrong.

So the rule is not merely "read outside, pass in" — it is the argument must identify the data. Passing scope is passing an argument, and it leaks anyway. The working form takes the identifying value as the argument:

demos/next-lab/lib/billing.tsgetUsageextracted
ts
import { cacheLife, cacheTag } from 'next/cache'import { db } from './db'
// uid is an ARGUMENT. It is therefore in the compiler-derived key, which// is the entire difference between a per-user entry and a shared one.export async function getUsage(uid: string) {  'use cache'  cacheLife('minutes')  cacheTag(`usage:${uid}`)  return db.usage.forUser(uid)}

The habit that falls out — read runtime data outside cached scopes, pass values in as arguments that identify the data — is the single most load-bearing habit in this model. It has its own article and its own recipe because it is the one whose failure is a security incident rather than a stale render.

Storage is not what it was

'use cache' defaults to in-memory storage, and the build ID is part of every key. Both matter operationally:

  • Entries do not survive a deployment. Every release starts cold.
  • Entries do not survive instance teardown. In serverless, that is often per-request in practice.

The fetch Data Cache and unstable_cache both persisted across deploys and instances. Teams migrating from 15 meet this as a cost and latency spike immediately after each release, not as a bug report. Durable storage is available — 'use cache: remote' or a configured cache handler — at the price of a network round trip on every cache check, and, on most platforms, a bill.

Validation is part of the model, not a linter

Because the framework can no longer guess, it tells you when you haven't decided. In development, Next.js validates whether navigating into each route renders instantly and surfaces what blocks it. Docs call the presentations errors and insights — but that is a presentation taxonomy, not a reliable map onto "stops the build" vs "doesn't."

The diagnostic is absent from the HTTP response — that is the weaker half. A flagged route still returns 200 with rendered HTML in next dev. The message lives in the overlay, the server log, or MCP get_errors. Agents and scripts that only curl the page will miss it.

Dev under-reports severity — that is the stronger half. The same diagnostic id can be advisory in next dev and fatal in next build. Bare connection() at a page root (no boundary, no instant = false) returns HTTP 200 in dev and exits the production build:

demos/next-lab/observations/insight-vs-build-error.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: scratch route app/scratch-s6-bare-connection with bare await connection() at page root (no Suspense, no instant=false); next dev --port 3020 + curl; then next build; remove scratch
=== DEV (next dev) ===HTTP status line: HTTP/1.1 200 OKresponse body length: 11706diagnostic present in HTML body: falsedev-log label: the line starts with "Error:" — not "Insight", not "Warning"dev-log lines of interest: GET /scratch-s6-bare-connection 200 in 10.0s (next.js: 9.6s, application-code: 372ms)Error: Route "/scratch-s6-bare-connection": Next.js encountered uncached data during prerendering.Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic    at Page (app\scratch-s6-bare-connection\page.tsx:4:19)
=== BUILD (next build) ===build exit: 1failure output:Error: Route "/scratch-s6-bare-connection": 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
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamicError occurred prerendering page "/scratch-s6-bare-connection". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /scratch-s6-bare-connection/page: /scratch-s6-bare-connection, exiting the build.
# note: same diagnostic id (blocking-prerender-dynamic). Dev serves 200; build exits 1. The log prefix is "Error:" in both; docs still call the non-fatal-in-dev presentation an "insight."

Earlier session-3 capture on app/insight-probe/ (root-level cookies() + uncached DB read, no Suspense, no instant = false) recorded the same pattern for the HTTP half — log prefix Error:, body silent. Overlay and MCP were not separately confirmed:

demos/next-lab/observations/insight-blocking-prerender.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: next dev; curl /insight-probe with root-level cookies()+uncached db read and no instant=falseHTTP 200Error: Route "/insight-probe": Next.js encountered uncached data during prerendering.
`fetch(...)` 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] Cache the data access with `"use cache"` (does not apply to `connection()`)  - [block] Set `export const instant = false` to allow a blocking route
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic# note: shipping app/insight-probe/page.tsx keeps instant=false so next build stays green; remove it to re-trigger

export const instant = false is the explicit "not yet" — it marks a segment as allowed to block. It is also the acknowledgement that quiets the validator: with the flag set, insights for that segment stop firing. That is precisely its purpose and precisely why it should be temporary — app/insight-probe/ ships with the flag so next build stays green; remove it locally to hear the insight again. Without the flag (or a boundary), the same diagnostic that looked advisory in dev fails the build.

Two things it is not:

  • It does not force the route dynamic. A genuinely prerenderable route still ships a static shell.
  • It does not clear synchronous-IO build errors. new Date(), Math.random(), and crypto.randomUUID() still fail the prerender, because those are correctness failures and instant = false is a latency statement.

Basic usage

The whole model in one route — runnable at /basic.

demos/next-lab/app/basic/page.tsxextracted
tsx
import { Suspense } from 'react'import { cacheLife, cacheTag } from 'next/cache'import { cookies } from 'next/headers'import { db } from '@/lib/db'
export default function BasicPage() {  return (    <main>      <Announcements />      <Suspense fallback={<p aria-busy="true">Loading stats…</p>}>        <PersonalStats />      </Suspense>    </main>  )}
async function Announcements() {  'use cache'  cacheLife('hours')  cacheTag('announcements')  const items = await db.announcements.findMany()  return (    <ul>      {items.map((a) => (        <li key={a.id}>{a.title}</li>      ))}    </ul>  )}
async function PersonalStats() {  const uid = (await cookies()).get('uid')?.value  if (!uid) return null  const stats = await getStats(uid)  return (    <p data-uid={uid}>      {stats.requests.toLocaleString('en-US')} requests · {stats.storageMb} MB    </p>  )}
async function getStats(uid: string) {  'use cache'  cacheLife('minutes')  cacheTag(`stats:${uid}`)  return db.usage.forUser(uid)}

Read it as three decisions, not as syntax: announcements are the same for everyone and an editor's publish invalidates them; personal stats differ per request; identity is read where the request exists and handed downward as a value.


Walkthrough — adopting the model on one real route

The most useful thing this article can teach is not the syntax. It is the loop you run when validation starts talking to you. We will take a dashboard that worked fine on Next 15 and drive it through.

Step 0 — the route as it arrives from Next 15

demos/next-lab/legacy/dashboard-page.next15.tsxextracted
tsx
// legacy: Next <16 implicit-caching model — see docs/evolution-ledger.md// NOT COMPILED. Excluded from tsconfig. Extraction target only.export const dynamic = 'force-dynamic'export const revalidate = 60
import { cookies } from 'next/headers'import { unstable_cache } from 'next/cache'
const getPlans = unstable_cache(  async () => db.plans.findMany(),  ['plans'],  { revalidate: 3600, tags: ['plans'] })
export default async function DashboardPage() {  const uid = (await cookies()).get('uid')?.value  const [plans, usage, alerts] = await Promise.all([    getPlans(),    db.usage.forUser(uid),    fetch('https://status.example.com/alerts').then((r) => r.json()),  ])
  return (    <main>      <PlanTable plans={plans} />      <UsageChart usage={usage} />      <AlertBanner alerts={alerts} />      <LastRefreshed at={new Date()} />    </main>  )}

Reasonable Next 15 code. Under cacheComponents: true it produces, in order: two config errors, one build error, and two insights.

Step 1 — enable the flag and delete what now errors

demos/next-lab/next.config.tsextracted
ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,}
export default nextConfig

dynamic and revalidate on a segment now error. Delete both. dynamic = 'force-dynamic' has no replacement — everything is dynamic by default, so the export was already describing the new baseline. revalidate = 60 becomes a cacheLife on whatever scope actually needed it, which is not the route.

At this point the route builds worse than it did before: everything renders per request, because nothing has opted in yet. This is expected and it is the honest state. The previous version was not faster; it was implicitly cached in ways nobody had chosen.

Step 2 — classify each of the four reads

Before writing code, answer the question for each one. This table is the actual work; the rest is transcription.

ReadSame for everyone?Invalidating write?Answer
getPlans()YesAdmin edits pricingCache it, tag plans
db.usage.forUser(uid)No — per userStream it (identity read outside)
fetch(status alerts)YesNone we control; third partyCache it, short lifetime
new Date()No — per requestStream it, after connection()

Note the third row. There is no write we can hook, so a tag is useless and the honest lever is time. And per the thresholds above, a short enough lifetime will take it out of the prerender — which is fine, as long as we know that is what we chose.

Step 3 — the cached reads

demos/next-lab/lib/billing.tsgetPlansextracted
ts
import { cacheLife, cacheTag } from 'next/cache'import { db } from './db'
export async function getPlans() {  'use cache'  cacheLife('days')  cacheTag('plans')  return db.mutablePlans.findMany()}
demos/next-lab/lib/billing.tsgetUsageextracted
ts
import { cacheLife, cacheTag } from 'next/cache'import { db } from './db'
// uid is an ARGUMENT. It is therefore in the compiler-derived key, which// is the entire difference between a per-user entry and a shared one.export async function getUsage(uid: string) {  'use cache'  cacheLife('minutes')  cacheTag(`usage:${uid}`)  return db.usage.forUser(uid)}

getUsage takes uid as an argument. It never reads the cookie. That is the whole difference between a per-user cache entry and a cross-user leak.

demos/next-lab/lib/status.tsgetAlertsextracted
ts
import { cacheLife } from 'next/cache'import { db } from './db'
/** * No cacheTag, deliberately: nothing in our system can invalidate a third * party's status feed, so a tag would be decoration. Time is the only * honest lever. * * cacheLife('minutes') keeps this in the prerender. 'seconds' would not — * its one-minute expire falls under the exclusion threshold. */export async function getAlerts() {  'use cache'  cacheLife('minutes')  return db.alerts.findMany()}

No tag, deliberately — nothing in our system can invalidate a third party's status feed. Time is the only honest lever. Measured on next@16.3.0: with cacheLife('minutes') the alert banner is present in .next/server/app/dashboard.html; flip to 'seconds' and it is absent from that file — present only in the streamed response. Lifetime moved the answer from "cache it" to "stream it."

Step 4 — the page becomes a layout of boundaries

demos/next-lab/app/dashboard/page.tsxextracted
tsx
import { Suspense } from 'react'import { cookies } from 'next/headers'import { connection } from 'next/server'import { getPlans, getUsage } from '@/lib/billing'import { getAlerts } from '@/lib/status'import {  PlanTable, UsageChart, AlertBanner, PlanTableSkeleton, UsageSkeleton,} from './parts'
export default function DashboardPage() {  return (    <main>      <Suspense fallback={<PlanTableSkeleton />}><Plans /></Suspense>      <Suspense fallback={<UsageSkeleton />}><Usage /></Suspense>      <Suspense fallback={null}><Alerts /></Suspense>      <Suspense fallback={null}><LastRefreshed /></Suspense>    </main>  )}
async function Plans() {  return <PlanTable plans={await getPlans()} />}
async function Usage() {  // Runtime read, outside the cached scope. The value is passed in.  const uid = (await cookies()).get('uid')?.value  if (!uid) return <p>Sign in to see usage.</p>  return <UsageChart usage={await getUsage(uid)} />}
async function Alerts() {  return <AlertBanner alerts={await getAlerts()} />}
async function LastRefreshed() {  await connection()  return <time>{new Date().toLocaleTimeString('en-US')}</time>}

Plans and Alerts are cached, so they resolve during the prerender and land in the shell — their <Suspense> boundaries cost nothing there. Usage reads a cookie, so its boundary is the hole. LastRefreshed needs a real request, and connection() says so.

Step 5 — verify the loop

bash
pnpm build && pnpm start
  1. After pnpm build, open .next/server/app/dashboard.html. The plan table and alert banner should be in that prerender artifact. The usage skeleton may be there as the hole's fallback; the timestamp from LastRefreshed should not. Recorded with cacheLife('minutes') on alerts:
demos/next-lab/observations/alerts-minutes-build.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: pnpm build with getAlerts cacheLife('minutes'); inspect .next/server/app/dashboard.htmlRoute (app)                    Revalidate  Expire├ ◐ /dashboard                         1m      1h○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed contentƒ  (Dynamic)            server-rendered on demanddashboard.html contains 'Scheduled maintenance': True
  1. Change getAlerts to cacheLife('seconds') and rebuild. Compare .next/server/app/dashboard.html again: the alert banner is absent from the prerender (it still arrives in the stream — curl will show it either way). Change it back. That round trip is the lifetime-affects-placement mechanism, observed rather than believed:
demos/next-lab/observations/alerts-seconds-build.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: pnpm build with getAlerts cacheLife('seconds'); inspect .next/server/app/dashboard.htmlRoute (app)                    Revalidate  Expire├ ◐ /dashboard                         1d      1w○  (Static)             prerendered as static content◐  (Partial Prerender)  prerendered as static HTML with dynamic server-streamed contentƒ  (Dynamic)            server-rendered on demanddashboard.html contains 'Scheduled maintenance': False
  1. Delete await connection() from LastRefreshed. The build fails with the same sync-IO error shape as the product page — new Date() has nowhere to abort. See Mistakes #8 for the recorded product-page form.
  2. Point two different sessions at the page and confirm the usage numbers differ. If they don't, a uid has leaked into a cached scope — try the module-capture exercise below if you need a reproduction of the uncaught form.

Step 5 is not optional. It is the only test in this list that catches a security bug.


Then vs now

This is the table the repository's evolution-ledger is built from; most other articles cite one row of it.

AspectNext 13–15Next 16+What changed underneath
Default postureCached unless opted outDynamic unless opted inCaching moved from route classification resolved during build planning to per-scope entries with compiler-derived keys. There is no longer one answer per route to classify, so there is nothing left for a default to guess.
Number of caches you reason aboutFour overlapping layers with four different opt-outsOne primitive you write, over the layers that remainThe Data Cache and Full Route Cache stopped being the mechanism and became storage details. 'use cache' is the only surface with an author-facing decision attached.
Cache keysWritten by hand: unstable_cache(fn, ['user', id], opts)Derived by the compiler from the build ID and serialized argumentsKey correctness moved from your discipline to the compiler's analysis — deleting a class of stale-key bugs and creating a new one, because anything read from ambient state at execution time cannot be in the key, and the framework can only stop you when that ambient state is a Next API it recognizes.
Lifetimeexport const revalidate = N on the segmentcacheLife(profile) inside the cached scope, with three clocksA whole-route declaration became a profile on one entry. One route can hold many entries with different lifetimes, which a segment config could not express — and lifetime now also determines where content can be delivered from.
Opting outdynamic = 'force-dynamic', noStore(), cache: 'no-store'Nothing; delete themThe opt-outs described a default that no longer exists. dynamic and revalidate are now build errors rather than no-ops, so the removal is enforced rather than trusted.
Persistencefetch Data Cache and unstable_cache survived deploys and instances'use cache' is in-memory and build-ID-keyed; durability is opt-inStorage moved from a framework-managed persistent layer to an in-process default with a pluggable handler. Every deploy now starts cold unless you pay for it not to.
Partial Prerenderingexperimental.ppr + experimental_ppr per segmentRemoved — it is the rendering modelAn opt-in experiment became the architecture, which is why there is no flag left to turn on.
Finding out you got it wrongRead the docs, guess, deploy, watch the cache-hit graphDev overlay / log / MCP naming the component — and next build that may treat the same diagnostic as fatalCorrectness moved from runtime observation to development-time validation, with the caveat that dev under-reports severity: a 200 in next dev proves neither correctness nor buildability.
InvalidationrevalidateTag(tag); revalidatePathcacheTag + updateTag (immediate, Server Actions only) or revalidateTag(tag, profile) (profile type-required by TypeScript; runtime still accepts a single argument and warns)Invalidation split along a semantic axis that used to be conflated: read-your-own-writes versus stale-while-revalidate are now different functions rather than the same call with different luck.

Real-world patterns

Adopt route by route, not app by app. The flag goes on, the segment configs come off, and instant = false parks anything not ready. A codemod (cache-components-instant-false) can apply the opt-out across every page, layout, and default in one pass; you then remove it one route at a time. Note the codemod's failure mode: given a wrong path it reports zero files handled rather than erroring, so check the count.

Cache the data access, not the route. 'use cache' at the top of a page produces one coarse entry keyed by almost nothing, and forbids every runtime read below it. Start at the function that touches the database and move up only with a measured reason.

Always call cacheLife in a cached scope. Not ceremony — it is what prevents an inner short-lived cache from silently dragging an outer scope's lifetime down, and it is what makes a cached function readable without tracing its callees.

Tag by entity, not by page. cacheTag('product:' + slug) survives a redesign; cacheTag('product-page') does not. The tag should name the thing that changes, and the write that changes it should be the thing that expires it.

Treat "no invalidating write" as a real answer. Third-party feeds, aggregate counts, anything you don't own the write path for: time is the only lever, and a tag would be decoration. Say so in a comment so the next reader doesn't add one.

Measure with next build && next start, always. Dev disables prefetching, behaves differently around cache entries, and under-reports severity for the same diagnostic id. Every caching conclusion in this repository is drawn from a production build (or an explicit next build failure).

Budget for cold caches at deploy. Because entries are build-ID-keyed and in-memory by default, the first minutes after a release are uncached. Either accept it, or reach for 'use cache: remote' on the handful of entries where the origin cost is genuinely painful.


API and type reference

SurfaceImportRole
cacheComponents: truenext.config.tsEnables the model. Requires the Node.js runtime.
'use cache'directiveMarks a file, component, or async function as cacheable. In-memory by default.
'use cache: remote'directiveSame, backed by a cache handler; durable across instances and deploys. Network round trip.
'use cache: private'directiveExperimental. Permits runtime APIs; browser-memory only, never stored on the server; unavailable in Route Handlers.
cacheLife(profile)next/cacheSets stale / revalidate / expire for the enclosing scope. Cannot be called at module scope.
cacheTag(tag)next/cacheAttaches an invalidation handle to the enclosing scope.
updateTag(tag)next/cacheExpires a tag immediately for read-your-own-writes. Server Actions only.
revalidateTag(tag, profile)next/cacheStale-while-revalidate expiry. Profile is type-required, runtime-deprecated without it — see tags-and-invalidation for the measured deprecation warning and the traced (not measured) immediate-expiry fallback.
connection()next/serverMarks the point past which prerendering must stop. Prohibited inside cached scopes.
export const instant = falsesegment configAllows a segment to block. Does not force dynamic; does not clear sync-IO errors.
cacheHandlersnext.config.tsCustom storage for 'use cache' and 'use cache: remote'. 'use cache: private' is not configurable.

Common mistakes

1. Copying a pre-16 caching idiom that still compiles. dynamic and revalidate now error, which is a mercy. The dangerous ones are the fetch options and patterns that still run and now mean something else.

2. 'use cache' at the top of a page to make it fast. One coarse entry, no runtime reads permitted below it, and no way to invalidate anything selectively.

demos/next-lab/antipatterns/use-cache-on-page.tsxextracted
tsx
// antipattern: 'use cache' on the page produces one coarse entry and// forbids every runtime read below it. Extract-only; never compiled.'use cache'
export default async function Page() {  return <main>everything below is now one cache entry</main>}

3. Assuming the framework will catch identity in a cached scope. It catches cookies() — at request time, not build time. It cannot catch a module-level variable set by the caller, and it cannot catch an argument that is the same for every user. The key contains the arguments; it does not contain who asked. This is the only mistake on this list that is a security incident.

demos/next-lab/lib/billing-leak-b.tsextracted
ts
import { cacheLife } from 'next/cache'import { db } from './db'
let lastSeenUid = 'anonymous' // set by an uncached caller before the call
export function rememberUid(uid: string) {  lastSeenUid = uid}
export async function getDashboardLeakB() {  'use cache'  cacheLife('minutes')  return db.usage.forUser(lastSeenUid)}

4. Omitting cacheLife. The default profile applies, and an inner short-lived cache — possibly in a dependency you didn't write — can drag the scope down. If the outer scope has no explicit lifetime and an inner one is short-lived, you get a prerender error instead, which is the framework doing you a favor loudly.

demos/next-lab/antipatterns/missing-cache-life.tsextracted
ts
// antipattern: a cached scope with no cacheLife. The default profile applies,// and an inner short-lived cache can drag this scope down — or produce a// prerender error when the outer scope has no explicit lifetime. Extract-only.import { cacheTag } from 'next/cache'import { db } from '../lib/db'
export async function getPlansMissingLife() {  'use cache'  // no cacheLife(...)  cacheTag('plans')  return db.plans.findMany()}

5. Expecting cacheLife('seconds') to be prerendered. Its one-minute expire disqualifies it. You asked to cache it; you got a streamed hole with a server cache in front. Usually correct — but know it happened.

6. Assuming cache entries survive a deploy. Build-ID-keyed and in-memory. Migrating off unstable_cache or the Data Cache, this shows up as a post-release cost spike.

7. Trusting a 200 in next dev. The diagnostic is absent from the HTTP response and may be fatal at next build. Dev under-reports severity — a passing page proves neither correctness nor buildability.

demos/next-lab/observations/insight-vs-build-error.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: scratch route app/scratch-s6-bare-connection with bare await connection() at page root (no Suspense, no instant=false); next dev --port 3020 + curl; then next build; remove scratch
=== DEV (next dev) ===HTTP status line: HTTP/1.1 200 OKresponse body length: 11706diagnostic present in HTML body: falsedev-log label: the line starts with "Error:" — not "Insight", not "Warning"dev-log lines of interest: GET /scratch-s6-bare-connection 200 in 10.0s (next.js: 9.6s, application-code: 372ms)Error: Route "/scratch-s6-bare-connection": Next.js encountered uncached data during prerendering.Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamic    at Page (app\scratch-s6-bare-connection\page.tsx:4:19)
=== BUILD (next build) ===build exit: 1failure output:Error: Route "/scratch-s6-bare-connection": 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
Learn more: https://nextjs.org/docs/messages/blocking-prerender-dynamicError occurred prerendering page "/scratch-s6-bare-connection". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /scratch-s6-bare-connection/page: /scratch-s6-bare-connection, exiting the build.
# note: same diagnostic id (blocking-prerender-dynamic). Dev serves 200; build exits 1. The log prefix is "Error:" in both; docs still call the non-fatal-in-dev presentation an "insight."

8. Reaching for instant = false at a synchronous-IO error. It is a latency opt-out and that is a correctness failure. new Date() in a prerendered path needs connection() behind a boundary, or a Client Component.

demos/next-lab/observations/sync-io-error.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: comment out await connection() in app/products/[slug]/price-as-of.tsx then pnpm buildError: Route "/products/[slug]": Next.js encountered the unstable value `new Date()` 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 callLearn more: https://nextjs.org/docs/messages/blocking-prerender-current-timeError occurred prerendering page "/products/aeron-chair". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /products/[slug]/page: /products/aeron-chair, exiting the build.

9. Tagging the page instead of the entity. cacheTag('dashboard') invalidates everything or nothing. Tag what changed.

demos/next-lab/antipatterns/page-tag.tsextracted
ts
// antipattern: tagging the page instead of the entity. cacheTag('dashboard')// invalidates everything or nothing. Extract-only; never compiled.import { cacheLife, cacheTag } from 'next/cache'import { db } from '../lib/db'
export async function getDashboardBundle() {  'use cache'  cacheLife('minutes')  cacheTag('dashboard')  return {    plans: await db.plans.findMany(),    alerts: await db.alerts.findMany(),  }}

10. Treating adoption as a migration you finish in one PR. The route-at-a-time loop exists because the classification work — deciding which of four answers each read gets — is the actual cost, and it is per-feature, not per-repo.


Exercises

1. Classify before you code. Take one route in an existing app and fill in the Step 2 table for every data read: same for everyone, invalidating write, chosen answer. Do not write code. If any row's "invalidating write" column is empty and you still wrote "cache it," change it.

Hint: aggregate counts and third-party feeds are the rows that expose the habit.

2. Watch a lifetime move an answer. In the walkthrough app, flip getAlerts between cacheLife('minutes') and cacheLife('seconds') and compare .next/server/app/dashboard.html each time — not the response body.

Hint: what you're looking for is the alert banner appearing in, then disappearing from, the prerender artifact. curl will show the alert either way because it follows the stream.

3. Build the leak, then fix it. Add a module-level let lastSeenUid to lib/billing.ts, set it from the uncached Usage component, and read it inside the cached function instead of taking uid as an argument. Build, start, then request the page twice with different uid cookies.

*Hint: UsageChart renders data-uid. If both requests return the same one, you have reproduced it. Recorded against the dedicated leak routes:

demos/next-lab/observations/leak-bc-curl.txtextracted
text
# produced: 2026-08-10; next@16.3.0; command: next start; curl with Cookie uid=... against /leak-b and /leak-c; grep data-uid# note: request order and warm cache matter for vector B; this capture hit bob first# leak-b — module-level lastSeenUidcurl -H "Cookie: uid=user-alice" /leak-b  ->  data-uid="user-bob"curl -H "Cookie: uid=user-bob"   /leak-b  ->  data-uid="user-bob"# leak-c — coarse key 'signed-in'curl -H "Cookie: uid=user-alice" /leak-c  ->  data-uid="user-alice"curl -H "Cookie: uid=user-bob"   /leak-c  ->  data-uid="user-alice"

Then try the same thing with cookies() inside the cached function and note that you get an error instead — the framework catches one form and not the other, and knowing which is which is the point.*


Summary

  • One decision inverts one default: nothing is cached unless 'use cache' says so.
  • The inversion happened because four implicit layers made "is this cached?" unanswerable by reading your own code.
  • Every read gets one of four answers: cache, stream, block, push to the client. If you can't name the invalidating write, it isn't "cache."
  • Lifetime is not just TTL. Short enough expire or stale removes content from the prerender, quietly converting "cache it" into "stream it."
  • Keys are compiler-derived from the build ID and the serialized arguments. Anything read from ambient state at execution time cannot be in the key — and the framework can only stop you when that ambient state is a Next API it recognizes. The argument must identify the data.
  • 'use cache' is in-memory and build-ID-keyed. Every deploy starts cold unless you opt into durable storage.
  • Validation is part of the model. Diagnostics are absent from the HTTP response, and the same id can be advisory in next dev and fatal in next build. A 200 in dev proves neither correctness nor buildability.

See also

  • foundations/thinking-in-the-app-router — the shell/hole rendering model this caching model sits inside
  • caching/use-cache-directive — exactly what enters a compiler-derived key
  • caching/cache-lifetimes — the three clocks, profiles, and nesting rules in full
  • caching/tags-and-invalidationupdateTag vs revalidateTag vs revalidatePath
  • caching/the-other-cache-layers — what survived from the four-layer model
  • data/runtime-data-and-cached-scopes — the read-outside-pass-in rule and its failure mode
  • migration/adopting-cache-components — the route-at-a-time loop at real scale

References

  • Next.js — next.config.js: cacheComponents
  • Next.js — Getting Started: Caching
  • Next.js — Guides: Migrating to Cache Components
  • Next.js — Directives: use cache, use cache: remote, use cache: private
  • Next.js — Functions: cacheLife, cacheTag, updateTag, revalidateTag, connection
  • Next.js — next.config.js: cacheHandlers, staleTimes
  • Next.js — Guides: Instant Navigation
  • Next.js blog — Next.js 16 (2025-10-21), Next.js 16.3 (2026-08-03)

Demo source

Extracted from:

  • demos/next-lab/next.config.ts
  • demos/next-lab/app/basic/page.tsx
  • demos/next-lab/app/dashboard/page.tsx (+ parts.tsx)
  • demos/next-lab/legacy/dashboard-page.next15.tsx
  • demos/next-lab/lib/billing.ts, lib/status.ts, lib/billing-leak-{a,b,c}.ts, lib/db.ts
  • demos/next-lab/app/leak-{a,b,c}/
  • demos/next-lab/antipatterns/ (use-cache-on-page.tsx, missing-cache-life.ts, page-tag.ts)
  • demos/next-lab/observations/ (sync-io-error.txt, leak-a-runtime-error.txt, leak-bc-curl.txt, alerts-minutes-build.txt, alerts-seconds-build.txt, insight-blocking-prerender.txt, insight-vs-build-error.txt)
  • demos/next-lab/app/insight-probe/ (insight measurement; ships with instant = false)

Verification status. Verified against next@16.3.0 (docs + demo). Measured (extracted): sync-IO prerender error; alerts leave .next/server/app/dashboard.html under cacheLife('seconds') while minutes keeps them; leak A runtime throw with digest; leaks B/C via data-uid; blocking-prerender-dynamic HTTP 200 in next dev and fatal in next build without instant = false / boundary (insight-vs-build-error.txt — severity-ladder claim falsified and rewritten). Traced: three-clock profiles and prerender-exclusion thresholds; nested-lifetime rules; updateTag Server-Action-only; instant = false neither forces dynamic nor clears sync-IO; codemod wrong-path 0 ok. Corrected (session 15): revalidateTag's profile argument is type-required, runtime-deprecated — not a runtime requirement — per the measured deprecation warning in tags-and-invalidation. Pending: DevTools MCP / overlay confirmation of the same surface (log-only so far); nothing else for this article's claims. Code blocks are extracted via scripts/build-article.py; this file is status: review.

0%