Skip to content
Malik Hamza Shabbir
Next.jsnext.jsuse cachemulti-tenantunstable_cache

Next.js 16 'use cache' Migration: Replacing Implicit Caching and unstable_cache Without a Cross-Tenant Leak

HSMalik Hamza ShabbirUpdated 9 min read

In short

In Next.js 16, the dangerous migration mistake is a 'use cache' function that reads the tenant from request context instead of taking it as an argument, which leaks one tenant's data to another. Migrate at function scope, pass tenant identity in as a serializable argument so it lands in the cache key, tag per tenant, and patch to 16.2.6+ first. Full audit checklist and the cross-tenant CI test are in the web development section below.

Next.js 16 'use cache' Migration: Replacing Implicit Caching and unstable_cache Without a Cross-Tenant Leak
On this page

If you are migrating to Next.js 16 and moving off unstable_cache and the old implicit Data Cache, the one bug that will actually hurt you is a cached function closing over a request-scoped value. When that happens, the cache stores one tenant's data and serves it to the next tenant who hits the same cache entry. The fix is structural, not cosmetic: migrate at function scope, never read auth or headers inside a 'use cache' function, and make tenant identity an explicit argument so it lands in the cache key.

I have done this migration on a multi-tenant SaaS in production, including after the 7 May 2026 cache-poisoning advisory that shipped in 16.2.6. This is the playbook I wish I had on day one.

What changed between unstable_cache and 'use cache' in Next.js 16?

Next.js 16 (GA October 2025) makes caching explicit and opt-in through cacheComponents and the 'use cache' directive, and it deprecates unstable_cache. The short version: the old implicit Data Cache that silently memoized your fetch calls is gone by default, and caching is now something you declare on a function or a component.

In the Next.js 13 to 15 era, a bare fetch() in a Server Component was cached unless you opted out, and unstable_cache was the escape hatch for non-fetch work like a database query. That implicitness is exactly what made cross-tenant bugs easy to write and hard to see. You did not opt in, so you did not think about the cache key.

With cacheComponents enabled, nothing is cached unless you say so. You annotate a function with 'use cache', and Next.js derives a cache key from the function's closure plus its serializable arguments. That sounds safer, and it is, but only if you understand what "closure" means here.

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

const nextConfig: NextConfig = {
  cacheComponents: true, // formerly the experimental dynamicIO flag
}

export default nextConfig








The deprecation matters for hiring conversations too. If you are reviewing a codebase that still leans on implicit fetch caching, that is a real migration cost, not a one-line swap.

How does a cached function leak one tenant's data to another?

It leaks when a request-scoped value, like the current user's tenant ID, is read from inside the cached function instead of passed in as an argument. The cache key never sees that value, so the first tenant's result gets stored under a key that the second tenant matches exactly.

Here is the failure, reduced to its core. This compiles, passes a single-tenant test, and ships.

TS
'use cache'
import { cookies } from 'next/headers'
import { db } from '@/lib/db'

// BROKEN: reads tenant from request context inside the cached fn
export async function getDashboardStats() {
  const tenantId = (await cookies()).get('tenant')?.value
  return db.stats.findMany({ where: { tenantId } })
}

The intent is "cache stats per tenant." What actually happens: the function takes no arguments, so its cache key is effectively constant across all callers. Tenant A loads the dashboard, the result is computed and stored. Tenant B loads the same route, hits the same empty-argument cache entry, and gets Tenant A's numbers. No error. No 500. Just one customer looking at another customer's revenue.

This is the bug nobody screenshots, because by the time you notice, the evidence is a confused support ticket, not a stack trace. I have walked into exactly this during an app-rescue engagement, and the team had been blaming a "flaky CDN" for two weeks.

The closure trap is subtle because reading cookies() or headers() inside 'use cache' should be a hard error, and in current 16.x it generally is flagged. But the more dangerous variants slip through: a value captured from a module-level singleton that was set per-request, an ORM client bound to a tenant at import time, or a helper that itself reads context two calls deep. The directive only protects you at the boundary it can see.

What is the correct function-scope-first migration pattern?

Make tenant identity an explicit, serializable argument and resolve all request-scoped values in the caller, never inside the cached function. The cached function should be a pure-ish unit: same arguments in, same data out, with no hidden dependence on who is asking.

TS
// cache.ts, the cached unit knows nothing about the request
'use cache'
import { cacheTag, cacheLife } from 'next/cache'
import { db } from '@/lib/db'

export async function getDashboardStats(tenantId: string) {
  cacheTag(`stats:${tenantId}`)
  cacheLife('minutes')
  return db.stats.findMany({ where: { tenantId } })
}

TS
// page.tsx, the caller resolves identity, then passes it in
import { cookies } from 'next/headers'
import { getDashboardStats } from './cache'

export default async function Page() {
  const tenantId = (await cookies()).get('tenant')?.value
  if (!tenantId) throw new Error('No tenant in request')

  // tenantId is now part of the cache key
  const stats = await getDashboardStats(tenantId)
  return <Dashboard stats={stats} />
}

Now tenantId is a serializable argument, so it participates in the cache key. Tenant A and Tenant B compute and store under different keys. The cacheTag gives you targeted invalidation: when Tenant A's data changes, you call revalidateTag('stats:' + tenantId) and you do not blow away everyone else's cache.

A few rules I hold to during the migration, in order of how often they save me:

  • The cached function takes everything it needs as arguments. If it needs the tenant, the tenant is a parameter.

  • Resolve cookies(), headers(), auth(), and any session lookup in the caller, above the cache boundary.

  • Every argument that distinguishes one tenant from another must be serializable. Functions, class instances, and bound clients do not serialize, so they do not key correctly.

  • Tag with the tenant ID so invalidation is surgical.


If you are doing a larger Next.js 16 move at the same time, the routing layer changed too, and I wrote up the middleware.ts to proxy.ts migration because auth resolution often lives right next to where you now need to read the tenant before the cache boundary.

What about the May 2026 cache-poisoning advisory?

The 7 May 2026 advisory, fixed in 16.2.6, is a separate class of problem from the closure bug, but they compound, so treat the patch as non-optional before you trust any caching layer. Upgrade to 16.2.6 or later first, then audit your own cache keys.

The distinction is worth keeping straight. The closure leak is a mistake in your code: you forgot to key on the tenant. Cache poisoning is a framework-level issue where an attacker can influence what gets stored under a cache entry through crafted requests. If you have both an unpatched runtime and sloppy cache keys, you have given an attacker a much larger surface, because now untrusted input can decide what fills a cache that you are also mis-keying.

My sequencing on every project right now:

  1. Pin to 16.2.6 or later. Do not migrate 'use cache' on an older 16.x and assume you will upgrade "soon."

  2. Re-run the function-scope audit below against the patched runtime.

  3. Only then turn cacheComponents on in production.


This is the same logic I applied to the React Server Components RCE earlier in 2026; the patch and the code audit are two different jobs and you need both. I covered the version math in the CVE-2025-55182 upgrade map , and the same "patch first, then verify your own assumptions" order applies here.

What does a multi-tenant cache audit checklist look like?

Grep for the directive, then for every cached function ask one question: does this function's output depend on anything not in its argument list? If yes, you have a leak waiting to happen. Here is the checklist I run, top to bottom.

First, find every cache boundary.

BASH
## every file that opts into caching
rg -l "['\"]use cache['\"]" --type ts --type tsx

## every legacy call still to be migrated
rg -n "unstable_cache" --type ts --type tsx

Then, for each 'use cache' function, walk this table.








Concernunstable_cache (13-15)'use cache' (Next.js 16)
Opt-in modelImplicit fetch cache + manual wrapExplicit directive, off by default
Cache key sourcekeyParts array you pass by handFunction closure + serializable args
Taggingtags option in third argcacheTag() inside the function
Lifetime controlrevalidate in third argcacheLife() profile inside the function
Status in 16DeprecatedRecommended
Cross-tenant riskHigh, silent key mistakesLower, but closures still bite
CheckWhat you are looking forFail signal
Request context readscookies(), headers(), auth() inside the fnAny of them present below the directive
Tenant in the signatureIs tenant ID a serializable parameter?Tenant resolved internally, not passed
Argument serializabilityAre all args plain data?A bound DB client or closure passed in
TaggingIs cacheTag scoped per tenant?Global tag or no tag
Transitive contextDo called helpers read context?A two-hops-down getSession()
LifetimeIs cacheLife set deliberately?Default lifetime on tenant data

The transitive row is the one that catches experienced teams. A cached function can look clean while calling a "harmless" utility that itself reads the session. The cache key only reflects the arguments at the boundary, so context read three calls deep is invisible to it. I trace every helper a cached function calls until I hit either pure data or a parameter.

A practical test harness: write a single integration test that calls each cached function as Tenant A, then as Tenant B, and asserts the results differ. If a function that should be tenant-specific returns identical data for two different tenants, your key is wrong. This catches the empty-argument bug instantly, and it is cheap to keep in CI.

TS
// pseudo-test: the cross-tenant assertion that should never pass quietly
const a = await getDashboardStats('tenant_a')
const b = await getDashboardStats('tenant_b')
expect(a).not.toEqual(b) // fails loudly if the key ignores tenant

This same discipline applies if you are building for AI agents that crawl your routes, since an agent hammering endpoints can surface a mis-keyed cache faster than human traffic; I touched on that surface in building Next.js apps for AI agents .

Should you migrate everything to 'use cache' at once?

No. Migrate the highest-risk, highest-traffic tenant-scoped functions first, leave genuinely public and static data for later, and never flip cacheComponents on across a large app in a single deploy. Incremental is safer and it isolates blast radius.

My ordering on a real SaaS migration:

  1. Anything that returns tenant-private data and is currently cached. This is where leaks live and where the consequences are worst.

  2. Per-user dashboards and lists, even if they feel low traffic, because these are the ones that look fine until two accounts collide.

  3. Truly public, tenant-agnostic data like a marketing page or a shared product catalog. Low risk, do it last.


For data that is genuinely the same for everyone, 'use cache' with no tenant argument is correct and fast. The whole skill is knowing which bucket a function belongs in, and the audit table above is how you decide. When I take on this kind of work as part of web development , the first deliverable is usually that inventory, not the code changes, because the inventory is where the leak hides.

If you are weighing whether to do this in-house or bring someone in who has already stepped on these specific landmines, I am happy to look at your cache boundaries with you. There is a short form on my contact page and I read every message myself.

The summary I give every team: caching in Next.js 16 is finally explicit, which is good, but explicit does not mean safe. A function that reads who is asking instead of being told who is asking will leak across tenants, and the directive cannot save you from a value it never sees. Pass identity in, key on it, tag by tenant, and test two tenants against each other in CI.

FAQ

Why does my Next.js 16 'use cache' function return the wrong tenant's data?

Because it reads the tenant from request context like cookies() inside the cached function instead of taking it as a serializable argument, so the tenant value never reaches the cache key and one tenant's stored result is served to the next.

Is unstable_cache removed in Next.js 16?

unstable_cache is deprecated in Next.js 16 in favor of the 'use cache' directive with cacheComponents, and you should migrate off it rather than rely on the old implicit Data Cache behavior.

How do I make a cached function tenant-safe?

Resolve cookies, headers, and auth in the caller, pass the tenant ID in as a serializable argument so it becomes part of the cache key, and tag the entry with cacheTag scoped to that tenant.

Do I need to patch before migrating to 'use cache'?

Yes, upgrade to Next.js 16.2.6 or later to get the 7 May 2026 cache-poisoning fix before you enable cacheComponents, because an unpatched runtime plus mis-keyed caches compound into a larger attack surface.

How can I test for cross-tenant cache leaks?

Write an integration test that calls each cached function as two different tenants and asserts the results differ, which fails loudly whenever a function ignores tenant identity in its cache key.

Working on something like this?

I build web apps, AI features, and mobile products for clients. If this article matches a problem you have, tell me about it.

Start a conversation
HS

Malik Hamza Shabbir · Full-Stack & AI Engineer

I build full-stack and AI products solo: a reputation SaaS in production, RAG pipelines, and React Native apps. I write from what I ship, not from documentation summaries.

Related articles