Next.js 16 'use cache' Migration: Replacing Implicit Caching and unstable_cache Without a Cross-Tenant Leak
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.

On this page
- What changed between unstable_cache and 'use cache' in Next.js 16?
- How does a cached function leak one tenant's data to another?
- What is the correct function-scope-first migration pattern?
- What about the May 2026 cache-poisoning advisory?
- What does a multi-tenant cache audit checklist look like?
- Should you migrate everything to 'use cache' at once?
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.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true, // formerly the experimental dynamicIO flag
}
export default nextConfig
| Concern | unstable_cache (13-15) | 'use cache' (Next.js 16) |
| Opt-in model | Implicit fetch cache + manual wrap | Explicit directive, off by default |
| Cache key source | keyParts array you pass by hand | Function closure + serializable args |
| Tagging | tags option in third arg | cacheTag() inside the function |
| Lifetime control | revalidate in third arg | cacheLife() profile inside the function |
| Status in 16 | Deprecated | Recommended |
| Cross-tenant risk | High, silent key mistakes | Lower, but closures still bite |
| Check | What you are looking for | Fail signal |
| Request context reads | cookies(), headers(), auth() inside the fn | Any of them present below the directive |
| Tenant in the signature | Is tenant ID a serializable parameter? | Tenant resolved internally, not passed |
| Argument serializability | Are all args plain data? | A bound DB client or closure passed in |
| Tagging | Is cacheTag scoped per tenant? | Global tag or no tag |
| Transitive context | Do called helpers read context? | A two-hops-down getSession() |
| Lifetime | Is 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.
// 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:
- Anything that returns tenant-private data and is currently cached. This is where leaks live and where the consequences are worst.
- Per-user dashboards and lists, even if they feel low traffic, because these are the ones that look fine until two accounts collide.
- 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 conversationMalik 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
middleware.ts to proxy.ts in Next.js 16: Migrating Auth Without Breaking Edge (and Why proxy Is Node-Only)
Next.js 16 renamed middleware.ts to proxy.ts and moved it to a Node-only runtime, so Edge-dependent auth can silently break on upgrade. Here is the exact fix: the codemod, the renamed config flags, the next-intl gotcha, and when to deliberately stay on the old file.
React Compiler 1.0 Broke My Forms: Fixing React Hook Form with 'use no memo' (and When to Wait for RHF v8)
After enabling React Compiler 1.0, my React Hook Form fields stopped updating. Here is why RHF's mutation-based internals fight auto-memoization, the precise 'use no memo' scoping that fixes it, the eslint lint plus autofix that catches it, and a clear rule on whether to patch now or wait for RHF v8.
The Bridge Is Gone: Migrating a Legacy React Native App to 0.85 When New Architecture Is the Only Option
React Native 0.85 fully removed the bridge on 7 April 2026, so the New Architecture is the only option. Here is how I audit dependencies against the Directory, which libraries still crash, and the realistic effort to rescue a 0.7x Paper app.