Next.js

Next.js 16 "use cache" Errors: Why Cache Components Break

Next.js 16 replaced implicit caching with the explicit "use cache" directive. Here is what actually breaks when you flip cacheComponents on, and how to fix it without reverting.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
10 min read

Next.js 16 flipped the caching model. Through Next.js 15, pages and fetches were cached by default and you opted out. With cacheComponents: true in Next.js 16, nothing is cached by default and you opt in with the 'use cache' directive. That's a bigger behavioral change than the version bump suggests, and it produces a specific, recurring family of build and runtime errors that generic "here's what use cache does" tutorials don't cover, because they're written against a fresh demo app, not a real one with i18n, auth, and third-party SDKs already wired in.

This is what actually breaks, and how to fix each case, based on the current 'use cache' docs and reports like amannn/next-intl#2229.

First: confirm you're actually opted in#

'use cache' only does anything once cacheComponents: true is set in next.config.ts. If you added the directive but nothing changed, that's the first thing to check — it's a silent no-op without the flag, which itself causes confusion ("I added use cache and nothing happened").

ts
// next.config.ts
const nextConfig = {
  cacheComponents: true,
}

Error 1 — "headers() / cookies() cannot be called inside 'use cache'"#

This is the single most common failure once you enable cacheComponents. Per the official constraint on request-time APIs, a function marked 'use cache' — directly, or indirectly through something it calls — cannot access cookies(), headers(), or searchParams; the restriction follows the whole call stack, not just the function you annotated. The confirmed next-intl case (#2229) is a textbook example: the Link component from createNavigation unconditionally reads request configuration through headers() internally, so wrapping a component that uses it in 'use cache' throws even though you passed a locale prop explicitly — the library ignores your prop and reaches into the request anyway.

Fix: cached functions must be pure with respect to the request. Move the 'use cache' boundary below anything that reads request state — cache the pure data-fetching function, not the component that also renders user-specific chrome. If a third-party library reads request context internally (i18n, auth SDKs, geolocation), don't wrap components that use it in 'use cache' at all; cache only the deterministic parts around it.

Error 2 — cached value never updates (missing cacheLife)#

If your cached function runs once and then never refreshes, you're missing cacheLife. Without it, Next.js applies a default profile that may be far more aggressive than you expect for a data-heavy page.

ts
import { cacheLife } from 'next/cache'
 
async function getPosts() {
  'use cache'
  cacheLife('minutes') // stale/revalidate/expire window — pick the built-in profile that matches your data
  return db.posts.findMany()
}

cacheLife was stabilised in Next.js 16.0.0 — import it directly from next/cache. The old unstable_cacheLife alias still works but is deprecated; if you're migrating from Next.js 15, this is one of the renames the v16 upgrade guide calls out.

cacheLife takes three practical knobs: how long the cache serves stale before checking, when it triggers a background refresh, and the hard expiry before eviction. Skipping it doesn't disable caching — it just applies defaults you didn't choose.

Error 3 — mutation doesn't invalidate the cache#

If a user updates data and still sees the old version, you're missing cacheTag plus a matching revalidateTag/updateTag call in the mutation.

ts
async function getProject(id: string) {
  'use cache'
  cacheTag(`project-${id}`)
  return db.projects.findUnique({ where: { id } })
}
 
// in the Server Action that mutates it:
import { revalidateTag } from 'next/cache'
revalidateTag(`project-${id}`)

Without a tag, there's nothing to invalidate — the cache entry sits until its cacheLife expiry regardless of what your mutation does.

Error 4 — caching the whole page captures runtime values it shouldn't#

'use cache' directly on a page component is officially supported — the docs call this UI-level caching and demonstrate it as-is. Putting it there isn't the mistake by itself. The actual trap is a page that also destructures or awaits params or searchParams inside that same cached scope: any value captured from an outer scope or passed as an argument automatically becomes part of the cache key, so if that value is the runtime-only params/searchParams promise, the whole page's output gets pinned to whatever resolved first (or fails to prerender at all). That's what produces the confusing "why is my whole page stale/stuck" reports.

Fix: keep the cached scope free of anything request-specific. Read params/searchParams/cookies()/headers() outside the 'use cache' boundary — or further down the tree, inside a <Suspense> boundary — and pass only the resolved, serialisable value in. Cache the data-fetching function or a child Server Component that receives already-resolved arguments, rather than a page that awaits runtime values directly inside its own cached body.

If you're migrating from unstable_cache#

unstable_cache still works, but it's the old, less granular API. The practical migration path is: replace unstable_cache(fn, keyParts, options) calls with a 'use cache' function plus explicit cacheLife/cacheTag calls — the tag/key logic maps roughly 1:1, but cacheLife profiles replace the old revalidate option with more granular stale/revalidate/expire control.

Error 5 — build fails asking for a Suspense boundary you didn't add#

This one is easy to confuse with Error 1, but it's a separate failure with a separate cause. Error 1 fires when a cached function reads request state. This one fires when an uncached dynamic API — searchParams, cookies(), headers(), or a fetch with no cache directive at all — is used directly inside a page or component that isn't wrapped in a <Suspense> boundary. With cacheComponents: true, Next.js needs to know exactly which parts of the page are static (prerenderable at build time) and which are dynamic (must run per-request). A dynamic API with nothing to catch it fails the build instead of silently falling back, because falling back silently is exactly the ambiguity cacheComponents was built to remove.

The fix is not to cache the dynamic part — it's to isolate it behind a Suspense boundary so the rest of the page can still prerender:

tsx
import { Suspense } from 'react'
 
export default function Page({ searchParams }) {
  return (
    <>
      <StaticHeader />
      <Suspense fallback={<Loading />}>
        <DynamicResults searchParams={searchParams} />
      </Suspense>
    </>
  )
}

Everything outside the boundary prerenders once at build time; everything inside it renders per request. This is the same underlying mechanism behind the useSearchParams() Suspense requirement from earlier Next.js versions — see useSearchParams Suspense Error: 3 Real Fixes for the client-side version of the same "wrap the dynamic part" pattern — except under cacheComponents it also applies to server-side dynamic APIs, and the build fails loudly instead of just warning.

'use cache' does not work inside Client Components#

'use cache' is a Server-side-only directive, the same family as 'use server'. It can mark a Server Component, a plain async function that runs on the server, or a Server Action — anywhere execution happens on the server and the result can be safely reused across requests. Adding it to a file that also has 'use client' at the top, or to a function that only ever runs in the browser, does nothing useful: there's no server-side cache to write into, because a Client Component's code runs on the user's machine, not on your server.

The failure mode here is quieter than the other four — it's rarely a hard build error, it's a directive that's simply inert. If you added 'use cache' to a hook or a component and nothing about its caching behavior changed, check whether that file is already a Client Component (either it has 'use client' itself, or it's imported into one and treated as part of the client bundle). The fix is to push the caching boundary down into a Server Component that the Client Component then receives data from as props, not to try to cache the client-side function directly. This is the same server/client data-flow boundary that governs Server Functions inside proxy-protected routes — server-only capabilities don't cross into client code just because they're imported into the same file tree.

If you're also hitting a /_not-found prerender crash while doing this migration, that's very likely a separate Next.js 16 issue, not this one — see Next.js 16 /_not-found prerender build error: the real fix. For the Next.js 15 caching model this replaces, see Next.js 15 caching, explained.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

Written by

Mahdi Ben Rhouma
Mahdi Ben Rhouma

Full-Stack Dev — Next.js & Supabase

Solo developer building SaaS products with Next.js and Supabase. Writing about production patterns the official docs skip.

Remote

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.