Next.js 16 unstable_cache Migration: Build Errors and the Fix

Next.js 16 build failing after replacing unstable_cache with 'use cache'? Here is why the closure error fires and the exact migration pattern that compiles.
Next.js 16 unstable_cache Migration: Build Errors and the Fix
Next.jsReactCaching
May 23, 20266 min read1098 words

The Problem

Upgraded a client project from Next.js 15.3 to 16.2 last week. The runtime was fine in dev. The production build blew up on the first cached helper I migrated from unstable_cache to the new 'use cache' directive:

Failed to compile.

./lib/products.ts
Error: Cached functions cannot reference variables from the enclosing scope.
Move the value inside the function or pass it as an argument.

  18 | export async function getProductsByCategory(category: string) {
  19 |   'use cache'
> 20 |   return db.products.findMany({ where: { category, locale } })
     |                                                       ^^^^^^

The locale variable was being read from a module-level constant set from an env var. Worked perfectly under unstable_cache, refused to compile under 'use cache'. Worse, a second helper that captured a request-scoped value from headers() threw a different error at runtime in dev mode:

Error: Route /products used `headers` inside a function annotated with 'use cache'.
Reads of request data are not allowed inside cached functions.

Two flavours of the same root problem: the closure model changed when the directive replaced the wrapper API, and the migration is not the one-line swap the upgrade guide makes it look like.

Why It Happens

unstable_cache was a higher-order function. You wrote a plain async function, wrapped it, and the cache key was whatever you passed into the keyParts array. Anything the closure captured from the outer scope went along for the ride because the wrapper still executed inside your module's context.

'use cache' is a directive, the same way 'use server' is. At build time the compiler lifts the function body out, serialises it, and registers it as an independent cache entry referenced by a stable hash of its source plus its arguments. That has two consequences. First, the function body cannot reference anything from the enclosing scope, because the lifted function has no enclosing scope at runtime. Second, the body cannot read request-scoped data like headers(), cookies(), or searchParams, because the cache entry is meant to be reusable across requests and those values are different on every one.

The compiler error catches the first case at build time. The runtime error catches the second case the first time the route renders. Both are protections, not bugs. They are how Next.js prevents a cached function from accidentally returning the wrong tenant's data because someone closed over a variable that looked stable but was not.

The use cache documentation explains the model in passing, but most teams hit the error before they get to that page.

The Fix

Two patterns. One for outer-scope captures, one for per-request data.

Pattern 1: Pass captured values as arguments. Anything the cached body needs becomes a parameter, including things that used to live as module constants. The cache key includes all arguments, so different values yield different entries:

// Before: captures `locale` from module scope, fails to build.
const locale = process.env.DEFAULT_LOCALE ?? 'en'

export async function getProductsByCategory(category: string) {
  'use cache'
  return db.products.findMany({ where: { category, locale } })
}

// After: locale is an argument, body has zero outer references.
export async function getProductsByCategory(
  category: string,
  locale: string,
) {
  'use cache'
  return db.products.findMany({ where: { category, locale } })
}

The call site changes to pass the value explicitly:

const locale = process.env.DEFAULT_LOCALE ?? 'en'
const products = await getProductsByCategory('shoes', locale)

This compiles, and the cache entry for ('shoes', 'en') is correctly distinct from ('shoes', 'fr'). If you forget to add the argument, the build catches it. That is the whole point of the rule.

Pattern 2: Read request data outside the cached function. When you need a header or cookie value to influence the cached query, read it in the caller and pass the result in:

// Wrong: runtime error, headers() inside a cached function.
export async function getCart() {
  'use cache'
  const tenant = (await headers()).get('x-tenant-id')
  return db.cart.findMany({ where: { tenant } })
}

// Right: caller resolves the tenant, the cached body is pure.
export async function getCartForTenant(tenantId: string) {
  'use cache'
  return db.cart.findMany({ where: { tenant: tenantId } })
}

// In the route or server component:
const tenantId = (await headers()).get('x-tenant-id') ?? 'default'
const cart = await getCartForTenant(tenantId)

The cached function now keys on tenantId, so each tenant gets its own cache entry, and the request-scoped read happens in the dynamic part of the render tree where it belongs.

Pattern 3: Use cacheTag and cacheLife for invalidation. The old unstable_cache API took tags and revalidate as wrapper options. Those move inside the function body:

import { unstable_cacheTag as cacheTag, unstable_cacheLife as cacheLife } from 'next/cache'

export async function getProductsByCategory(category: string, locale: string) {
  'use cache'
  cacheTag(`products:${category}`, `products:${category}:${locale}`)
  cacheLife('hours')
  return db.products.findMany({ where: { category, locale } })
}

The tags must be string literals or template strings built from the function's arguments. Building a tag from a captured variable will produce the same build error as a captured query parameter.

Verify the migration before shipping. Build locally with next build, then run a smoke test that hits each migrated helper twice and confirms the response time drops on the second call:

time curl -s https://localhost:3000/products/shoes > /dev/null
time curl -s https://localhost:3000/products/shoes > /dev/null

The second request should land in single-digit milliseconds. If it does not, either the function is not actually cached (check for missed directives) or you are passing a value that changes per request, which puts every call into its own cache entry.

The Lesson

'use cache' is not a drop-in replacement for unstable_cache. The body becomes a lifted, pure function that cannot reach outside scope and cannot read request data. Move captured values to arguments, resolve headers and cookies in the caller, and put cacheTag and cacheLife inside the body. The compiler errors are the migration checklist.

If your Next.js 16 upgrade is stuck on cache rewrites, that is a project I get paid to untangle. See my services. For a related caching gotcha after upgrading, read Next.js use cache returning stale data.

Stuck on a Next.js 16 upgrade? Get it shipped.

Back to blogStart a project