Engineering 8 min read

Next.js Cache Components: What Breaks After the Build

Papan Sarkar
Papan Sarkar

I flipped cacheComponents: true on a client dashboard last month, ran the codemods, fixed the errors the dev overlay pointed at, and got a green build on the second try. Staging looked fine. Then the database graphs went the wrong way after the next deploy, and a support ticket came in about a filter panel that would not close.

None of the three things that went wrong showed up in next build. That is the part of this migration worth writing down, because the release notes cover the new syntax thoroughly and say very little about what the cache now is.

The easy half: the default flipped

Next.js 16 arrived on October 21, 2025, and the headline change is that caching is opt-in. In the old App Router, a route was static until something made it dynamic, and half the job was remembering which API secretly did that. Now, as the release post puts it, all dynamic code in any page, layout, or API route executes at request time by default.

The mechanical part of the migration follows from that and is genuinely mechanical. dynamic = "force-dynamic" is no longer needed because every page is dynamic already. revalidate becomes cacheLife. fetchCache disappears. unstable_noStore() disappears. There is a codemod that stamps instant = false on every segment so the app builds while you convert routes one at a time.

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

If you stop reading the docs here, you will do what I did: treat use cache as a rename of unstable_cache and move on.

use cache is not the Data Cache

This is the one that cost me. The migration guide tells you to replace unstable_cache with use cache, turning the wrapped function into a directive and mapping the options onto cacheLife and cacheTag:

// before
export const getUser = unstable_cache(
  async (id: string) => db.query.users.findFirst({ where: eq(users.id, id) }),
  ['user'],
  { tags: ['users'], revalidate: 3600 }
)

// after
import { cacheLife, cacheTag } from 'next/cache'

export async function getUser(id: string) {
  'use cache'
  cacheLife('hours')
  cacheTag('users')
  return db.query.users.findFirst({ where: eq(users.id, id) })
}

Same shape, better ergonomics, no key-parts array. What the snippet does not tell you is that the storage underneath is different. The use cache reference is blunt about it: with the default in-memory handler, entries do not carry over to a new deploy, because the cache key includes the build ID. The same page states that on serverless, cache entries typically do not persist across requests at all, since each request can land on a different instance.

unstable_cache and the fetch Data Cache do not work that way — both persist across deployments and across serverless instances. So a straight swap converts a cache that survived deploys into one that empties on every deploy, and on serverless, one that may never get a hit at all.

That is what my database graphs were showing. The dashboard deploys several times a week, and every deploy was a cold start against Postgres for queries that used to be warm for days.

The docs contain their own resolution, in the opposite order from the one you read them in. The reference page says that for data that needs to persist across deploys, use unstable_cache for non-fetch functions or the fetch cache — that is, keep the thing the migration guide told you to remove. The alternative is use cache: remote, which lets the platform supply a dedicated handler such as Redis, and which the same page notes requires a network roundtrip and typically incurs platform fees.

The rule I settled on: use cache is for filling the static shell at build time, which is what it is designed for. If an entry needs to outlive a deploy, it needs a real cache handler or it stays on unstable_cache. Deciding that per function took an afternoon and was the only part of this migration that needed judgement rather than a codemod.

The error that passes the build and fails at runtime

Cached functions cannot read cookies(), headers(), or searchParams, and the restriction follows the call stack — a helper called from inside a cached scope fails the same way. That is reasonable. The trap is when you find out. From the reference: on a dynamically rendered route the violation surfaces when the route runs, so it can pass next build and fail under next start.

A shared getCurrentTenant() helper three calls deep is exactly the shape that slips through. The fix is the pattern the docs recommend anyway — read request data outside the cached scope and pass the value in as an argument, where it becomes part of the cache key:

// the route reads the request, the cache takes a plain value
async function TenantPanel() {
  const tenantId = (await cookies()).get('tenant')?.value
  return <Panel data={await getPanelData(tenantId)} />
}

async function getPanelData(tenantId: string) {
  'use cache'
  cacheLife('minutes')
  return db.panels.findMany({ where: { tenantId } })
}

Two related failure modes are worth knowing before you hit them. Passing a runtime promise into a cached function instead of an awaited value does not error — it hangs, and the build times out after 50 seconds with a message about request-specific arguments. And synchronous IO like new Date() or Math.random() during prerender throws a build error that instant = false does not clear, so it cannot be deferred with the rest of your migration backlog.

Run next start against a production build before you believe a Cache Components migration. The dev overlay and the build are both blind to a class of error that only appears when a route actually serves a request.

The filter panel that would not close

The support ticket was the strangest one. Under Cache Components, Next.js preserves routes with React’s <Activity> component in hidden mode instead of unmounting them, which means useState values, form inputs, and scroll position are no longer reset when navigating away and back.

React’s own reference explains the mechanism: a hidden Activity boundary visually hides its children with display: none and destroys their Effects, while saving their state for later. Conceptually the children unmount; practically, the state is still sitting there when you come back.

For most of the app this is a feature — scroll position survives a back navigation for free. For anything that relied on unmounting as its reset mechanism, it is a bug. Dropdowns and popovers stay open. A form that showed a success message still shows it when you return. Dialogs whose initialization Effects depend on their own state do not re-fire, and the docs recommend deriving that state from the URL instead.

My filter panel was a useState boolean toggled by a button, cleaned up by nothing, because navigating away had always cleaned it up. Closing it in a useLayoutEffect cleanup was a two-line fix. Finding it was not, because it reproduced only on back-navigation and never on reload.

What I would tell myself before starting

Budget the migration in two parts. The syntax conversion is a day, mostly codemods, and the validation overlay is good at telling you what to change. The second part is auditing every cache you converted and asking what happens to it on deploy, because that answer changed and nothing in your build output mentions it.

Three checks that would have saved me the week:

  • For each converted unstable_cache call, decide explicitly whether the entry must survive a deploy. If it must, it does not belong on default use cache.
  • Run next start and exercise the authenticated routes. Cached-scope violations on dynamic routes do not appear until a request runs.
  • Click backwards through the app. Anything that used to reset on unmount now needs an explicit reset.

The model is better than what it replaced — implicit caching that you disabled by accident was worse than explicit caching you have to think about. But “opt-in caching” undersells the change. The cache you opt into has different persistence, different failure timing, and a different relationship with navigation than the one you had, and the migration guide is organised around syntax rather than around any of those.

Sources