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 |
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.
'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.
// 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 } })
}// 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:
- Pin to 16.2.6 or later. Do not migrate
'use cache'on an older 16.x and assume you will upgrade "soon." - Re-run the function-scope audit below against the patched runtime.
- Only then turn
cacheComponentson 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.
## 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 tsxThen, for each 'use cache' function, walk this table.
| 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 tenantThis 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.
Run it on yours
Every number above came from code you can read.
The harness is open and the per-probe log ships with it. If you build one of these systems, or you are choosing between them with real money, the same method is available to you.
Send me an endpoint and I will write an adapter, run it on the same workload under the same rules, and publish the result. It goes in the table whether it wins or not.
Sponsored inclusion is disclosed in the article and the result publishes either way. Paying moves you up the queue, never up the table.
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.