Next.js 'use cache' Directive: Complete Guide with cacheLife, cacheTag & Migration from unstable_cache (2026)
Author
Muhammad Awais
Published
June 12, 2026
Reading Time
18 min read
Views
18k

You're Probably Caching Wrong in Next.js 16 - Here's the Fix
Picture this: you upgrade your Next.js app from 15 to 16, run next build, and suddenly your previously-static pages are all rendering dynamically. Traffic spikes hit your database hard. Something changed under the hood and if you've been relying on the old fetch(url, { next: { revalidate: 60 } }) pattern or wrapping everything in unstable_cache, you've just hit the most significant caching overhaul Next.js has shipped in years.
Next.js 16 replaced the entire implicit caching model with a single, explicit primitive: the 'use cache' directive. Data fetching is now dynamic by default. You opt into caching not out of it. For teams that haven't read the release notes carefully, this is a production breaking change dressed up as a quality-of-life improvement.
I've spent time migrating real apps through this shift, and this guide covers everything: what 'use cache' actually does, how cacheLife and cacheTag work, when to use all three directive variants, and a complete migration path away from the now-deprecated unstable_cache.
What the
'use cache'directive is and how cache keys are generated automaticallyThe three cache scopes: file, component, and function level
How to control freshness with
cacheLifeprofilesHow to invalidate cache entries on-demand with
cacheTagandrevalidateTagThe three directive variants:
'use cache','use cache: remote', and'use cache: private'Step-by-step migration from
unstable_cacheReal-world patterns for SaaS apps and multi-tenant scenarios
What Is the 'use cache' Directive and Why Does It Exist?
The 'use cache' directive is a compiler-integrated caching primitive introduced as stable in Next.js 16.2. You place it as the first statement inside a file, async component, or async function and Next.js takes over from there. No wrapper functions. No manual cache key arrays. The compiler analyzes your function's arguments and any variables captured from outer scopes, then automatically generates a deterministic cache key for you.
Before Next.js 16, caching was a patchwork of mechanisms: page-level export const revalidate, per-fetch { next: { revalidate } } options, unstable_cache for non-fetch data, and React's own cache() for deduplication. Each had subtly different semantics, different invalidation APIs, and different footguns. I once spent two hours debugging a stale user dashboard because an unstable_cache call was capturing a user ID from the wrong closure scope. Sound familiar?
Next.js 16 collapses all of that into one model. You need to enable it first 'use cache' does nothing without the cacheComponents flag in your config.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigOnce that flag is set, the entire Cache Components model is active: data fetching becomes dynamic by default, and you selectively mark what should be cached. It's a fundamentally different mental model but a more honest one. You know exactly what's cached and exactly why.
The Three Scopes: File, Component, and Function Level
The 'use cache' directive can be applied at three different scopes, and choosing the right one matters. Start granular function level then expand to component or file scope once behavior is stable and verified.
Function-Level Caching (Start Here)
This is the most common pattern and the one I recommend for any migration. You put 'use cache' as the first statement inside an async function. The cache key includes the function's build ID, its location hash, and all serializable arguments (including closure variables):
// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getProductById(productId: string) {
'use cache'
cacheLife('hours')
cacheTag(`product-${productId}`)
const product = await db.products.findUnique({ where: { id: productId } })
return product
}Call getProductById('abc-123') twice in the same request? Second call hits the in-memory cache. Call it again in a later request within the cache lifetime? Still cached. Next.js handles all the key generation productId is automatically part of the key because it's a function argument.
Component-Level Caching
For Server Components that are expensive to render think dashboard widgets that aggregate multiple database queries you can cache at the component level:
// components/analytics-widget.tsx
import { cacheLife } from 'next/cache'
export async function AnalyticsWidget({ orgId }: { orgId: string }) {
'use cache'
cacheLife('minutes')
const stats = await db.analytics.aggregate({ where: { orgId } })
return <div>{/* render stats */}</div>
}Note: component arguments (props) must be serializable. Passing a class instance as a prop to a cached component throws a build error. Stick to strings, numbers, plain objects, and arrays.
File-Level Caching
Place 'use cache' at the very top of a file to mark every export in that file as cached. This is a good consolidation pattern once individual functions are stable, but every function export in the file must be async:
'use cache'
// Every async function exported from this file is now cached
export async function getCategories() {
return db.categories.findMany()
}
export async function getFeaturedProducts() {
return db.products.findMany({ where: { featured: true } })
}Controlling Freshness with cacheLife Profiles
By default, a cached function stays fresh forever (until the next build). That's rarely what you want. cacheLife() is how you tell Next.js how long a cache entry should live and it uses a stale-while-revalidate model, not a hard expiry.
Next.js ships with built-in profiles: 'seconds', 'minutes', 'hours', 'days', 'weeks', and 'max'. Each profile has three time values:
stale: how long the cached result is served without checking for freshness
revalidate: how long before the cache entry is considered expired and regenerated in the background
expire: the hard upper limit after this, the entry is dropped entirely
import { cacheLife } from 'next/cache'
// Using a built-in profile
export async function getBlogPosts() {
'use cache'
cacheLife('hours')
return db.posts.findMany({ where: { published: true } })
}
// Using a custom config object — more control
export async function getUserDashboard(userId: string) {
'use cache'
cacheLife({
stale: 300, // 5 minutes: serve stale without checking
revalidate: 900, // 15 minutes: regenerate in background after this
expire: 3600, // 1 hour: hard drop — never serve content older than this
})
return db.dashboard.getUserData(userId)
}One gotcha worth pinning: profiles with very short lifetimes specifically 'seconds', or any custom config where revalidate or expire is under 5 minutes are automatically excluded from static prerenders. They become "dynamic holes" in the page instead. This is intentional behavior, not a bug.
You can also call cacheLife() conditionally inside the same function. Useful when a missing record should be cached briefly (it might appear soon) but an existing one can be cached for days:
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
const post = await db.posts.findUnique({ where: { slug } })
cacheTag(`post-${slug}`)
if (!post) {
cacheLife('minutes') // might get published soon
return null
}
cacheLife('days') // published content — cache aggressively
return post
}On-Demand Invalidation: cacheTag, revalidateTag, and updateTag
Time-based caching is fine for content that changes on a schedule. But when a user submits a form, edits their profile, or publishes a post, you want the cache gone now not after the next revalidation cycle. That's what cacheTag and revalidateTag are for.
Tagging Cache Entries
Inside a 'use cache' function, call cacheTag() to attach one or more string identifiers to that cache entry. Tags let you later invalidate a specific entry without touching anything else:
import { cacheTag } from 'next/cache'
export async function getOrdersByUser(userId: string) {
'use cache'
cacheTag(`orders:${userId}`, 'orders-list')
return db.orders.findMany({ where: { userId } })
}Invalidating on Mutation
When a user creates an order, call revalidateTag or updateTag from a Server Action. Here's the practical difference:
revalidateTag: serves stale content while regenerating in the background (stale-while-revalidate semantics). Use this from Route Handlers and when slight staleness is acceptable.
updateTag: for read-your-own-writes scenarios inside Server Actions. The user who just mutated data gets fresh content on their next navigation immediately.
// app/actions/orders.ts
'use server'
import { revalidateTag, updateTag } from 'next/cache'
export async function createOrder(userId: string, orderData: OrderInput) {
const order = await db.orders.create({ data: { userId, ...orderData } })
// Use updateTag for the user who just created the order
// — they should see it immediately
updateTag(`orders:${userId}`)
// Revalidate the shared orders list in the background
revalidateTag('orders-list')
return order
}For real-world e-commerce or SaaS apps, this pattern completely replaces the old revalidatePath('/orders') approach and it's much more precise. You're not flushing an entire route's cache; you're invalidating only the exact data that changed.
If you want to see how this fits into the broader revalidation and routing model, the existing guide on mastering revalidatePath and revalidateTag in Next.js covers the older patterns that this new model is gradually replacing.
The Three Directive Variants: When to Use Each One
Next.js 16 ships three caching directives, not one. Most developers only discover the other two after hitting a production bug. Here's the complete picture:
'use cache' - The Default (Server-Side In-Memory)
The standard directive. Cache entries are stored in server memory using an LRU strategy. Fast, no external dependencies, no cost. But there's an important constraint: you cannot access runtime APIs like cookies(), headers(), or searchParams directly inside the cached scope. You must read them outside and pass them as arguments:
// ❌ WRONG — throws an error
export async function getCart() {
'use cache'
const cookieStore = cookies() // Not allowed inside 'use cache'
const sessionId = cookieStore.get('session-id')?.value
return db.carts.findUnique({ where: { sessionId } })
}
// ✅ CORRECT — read cookies outside, pass value in as argument
export default async function CartPage() {
const cookieStore = await cookies()
const sessionId = cookieStore.get('session-id')?.value ?? ''
const cart = await getCart(sessionId)
return <CartView data={cart} />
}
async function getCart(sessionId: string) {
'use cache'
cacheTag(`cart:${sessionId}`)
return db.carts.findUnique({ where: { sessionId } })
}Also worth knowing: in serverless environments, in-memory cache entries typically don't persist across requests. Each cold start is a fresh cache. If you're on Vercel or a similar serverless platform and you're seeing higher-than-expected database query rates, this is why.
'use cache: remote' - External Cache Handler (Redis/KV)
For apps where cache persistence across serverless instances actually matters, 'use cache: remote' tells Next.js to delegate storage to an external cache handler Redis, Vercel KV, or any custom handler you configure. The cache survives between serverless function invocations and is shared across instances:
export async function getGlobalLeaderboard() {
'use cache: remote'
cacheLife('hours')
cacheTag('leaderboard')
return db.scores.findMany({ orderBy: { score: 'desc' }, take: 100 })
}The trade-off is real: each cache read now requires a network roundtrip to your cache handler, and most platforms charge for KV storage reads. Use 'use cache: remote' when the cost of a database miss is higher than the cost of a Redis read — typically for expensive aggregations, global counters, or shared data that gets hit thousands of times per minute.
'use cache: private' - Browser-Only Cache (No Server Storage)
This is the experimental one, added to address a specific compliance and security need. 'use cache: private' caches the result only in the browser's memory nothing is stored on the server. It also allows accessing runtime APIs like cookies() and headers() directly inside the scope, which the other two variants prohibit.
export async function getUserProfile() {
'use cache: private'
// Allowed here — cookies() is accessible
const cookieStore = await cookies()
const token = cookieStore.get('auth-token')?.value
return fetchUserFromToken(token)
}Use this when: GDPR or compliance requirements explicitly prohibit storing personalized data on servers, or when you're working with a function that deeply accesses runtime request data and refactoring it to pass values as arguments isn't practical. It's still experimental as of June 2026 don't ship it in production-critical paths without testing.
Migrating from unstable_cache to 'use cache'
If you're running Next.js 14 or 15 with unstable_cache, you're living on borrowed time. The API is still there in 16, but the docs explicitly flag it as replaced. Here's the migration pattern:
// ❌ Next.js 14/15 — the old way
import { unstable_cache } from 'next/cache'
const getCachedProducts = unstable_cache(
async (categoryId: string) => {
return db.products.findMany({ where: { categoryId } })
},
['products-by-category'], // manual key array — you had to maintain this yourself
{
revalidate: 3600,
tags: ['products'],
}
)
// ✅ Next.js 16 — the new way
import { cacheLife, cacheTag } from 'next/cache'
export async function getProductsByCategory(categoryId: string) {
'use cache'
cacheLife('hours')
cacheTag('products', `category-${categoryId}`)
return db.products.findMany({ where: { categoryId } })
}The migration looks simple but there's a real trap: unstable_cache required you to manually specify the cache key parts. You could easily miss a dependency. The new directive captures closure variables automatically which means if you migrate a function that was accidentally broken due to a missing key part, the new version will produce more cache misses while things stabilize. That's actually the correct behavior. Treat the first deployment after migration as a cache warmup period.
For large codebases, Next.js provides a codemod. Run it, then audit every // TODO comment it leaves behind each one is a place where the cache behavior changed and needs manual review. Don't treat the codemod output as done. Treat it as a review checklist.
npx @next/codemod@latest use-cache-upgrade .Also update your next.config.ts the experimental.dynamicIO and experimental.useCache flags from the Next.js 15 canary period are now deprecated. Replace them with the top-level cacheComponents: true:
// ❌ Old Next.js 15 canary config
const nextConfig = {
experimental: {
dynamicIO: true,
useCache: true,
},
}
// ✅ Next.js 16 config
const nextConfig: NextConfig = {
cacheComponents: true,
}Two Mistakes That Will Bite You in Production
I've seen both of these cause incidents on real apps. They're subtle enough that they slip through code review.
Caching user-specific data with the shared directive: If you cache a function that queries user-specific data using plain
'use cache'(the server-side shared cache), and that function captures a user ID from a session cookie inside the cached scope, you risk serving one user's data to another. This is a multi-tenant data leak. Always pass user identifiers as function arguments never read them inside the cache scope or use'use cache: private'for genuinely user-scoped data. In SaaS apps this is a security issue, not just a bug.Calling cacheTag or cacheLife outside a 'use cache' scope: Both functions must be called inside a function or file whose first statement is the
'use cache'directive. If you move them into a helper or call them conditionally from a non-cached context, Next.js throws a build error. This one's easy to trigger when refactoring.
Related performance reading: if you're also dealing with client-side rendering bottlenecks, the guide on fixing unnecessary re-renders in React 2026 covers the client-side counterpart to what 'use cache' handles on the server.
Serverless vs Self-Hosted: Cache Behavior Differs
This is the part most tutorials skip and it explains a lot of production confusion.
Serverless (Vercel, AWS Lambda, etc.): In-memory cache entries typically don't persist between requests. Each new function instance starts cold.
'use cache'still provides value it deduplicates within a single request, it informs Next.js what can be prefetched, and it defines stale times for client-side navigation. But if you're expecting cross-request cache persistence, use'use cache: remote'with a proper cache handler.Self-hosted (Docker, VMs, long-running Node processes): In-memory cache persists across requests in the same process. The LRU strategy evicts old entries when memory pressure is high, but for a stable traffic pattern your hot data stays warm. Use
cacheMaxMemorySizein your config to tune the LRU capacity.
If your app is on a serverless platform, always verify caching behavior in production mode (next build && next start locally with production env vars), not in next dev. Caching in development mode intentionally behaves differently.
For database-heavy Next.js apps on serverless infrastructure, the article on Prisma and PostgreSQL connection pooling for serverless Next.js is directly relevant smart caching at the application layer reduces the connection churn that kills serverless Prisma apps.
Practical Patterns: E-Commerce, Blog, and SaaS Examples
Here's how 'use cache' fits into three common app archetypes:
E-Commerce Product Pages
// Cache product data for hours — prices change infrequently
export async function getProduct(productId: string) {
'use cache'
cacheLife('hours')
cacheTag(`product:${productId}`, 'products')
return db.products.findUnique({ where: { id: productId } })
}
// Cache inventory separately — it changes more frequently
export async function getInventory(productId: string) {
'use cache'
cacheLife('minutes')
cacheTag(`inventory:${productId}`)
return db.inventory.findUnique({ where: { productId } })
}Blog / CMS
// Cache published posts for days — use tags for on-demand revalidation via webhook
export async function getPost(slug: string) {
'use cache'
cacheLife('days')
cacheTag(`post:${slug}`, 'posts')
return cms.posts.getBySlug(slug)
}
// In your CMS webhook Route Handler:
// import { revalidateTag } from 'next/cache'
// revalidateTag(`post:${slug}`) // fires when editor hits "Publish"SaaS Dashboard
// Cache org-level analytics — expensive query, org-scoped data
export async function getOrgAnalytics(orgId: string) {
'use cache'
cacheLife({
stale: 300, // 5 min
revalidate: 900, // 15 min background refresh
expire: 7200, // 2 hr hard expire
})
cacheTag(`analytics:${orgId}`)
return db.events.aggregate({ where: { orgId } })
}Notice that each function has its own cacheLife product prices, inventory, posts, and analytics all have different freshness requirements. The old export const revalidate = 60 at the page level forced everything on the page to share one revalidation time. Composable per-function caching is a genuine improvement.
If you're building data-heavy Next.js apps and want to understand the full rendering picture including how Server Components fit into all of this the breakdown of React Server Components vs Client Components in Next.js 2026 covers the rendering model that 'use cache' builds on top of.
WebToolsHub tools run entirely in your browser no data is ever sent to any server. Completely free, no account required.
For a deep dive into the full caching model, the official Next.js 'use cache' directive documentation is well-maintained and updated with every stable release. For invalidation specifics, the cacheTag API reference covers every edge case including multi-tag entries, idempotency, and the difference between revalidateTag and updateTag.
Conclusion
The 'use cache' directive is the most important caching change in Next.js since the App Router launched. It trades the implicit, hard-to-reason-about model of Next.js 14/15 for something explicit, composable, and genuinely production-ready. Yes, upgrading means auditing your entire data layer. But the result is a codebase where you know exactly what's cached, for how long, and how it gets invalidated instead of hoping the framework's defaults happen to be doing the right thing.
The summary: enable cacheComponents: true, migrate unstable_cache calls to 'use cache' functions, pair every cached function with a cacheLife() profile and a cacheTag(), and use revalidateTag or updateTag after mutations. For serverless apps, reach for 'use cache: remote' when you need cross-instance persistence. For user-specific data with compliance constraints, evaluate 'use cache: private'.
The Next.js team built this to scale and it does. The key is understanding the model before you reach for it in production.
Frequently Asked Questions
What is the 'use cache' directive in Next.js 16?
'use cache' is a compiler-integrated directive that marks a file, Server Component, or async function as cacheable. When used, Next.js automatically generates a cache key from the function's arguments and captured closure variables, stores the result in memory using an LRU cache, and reuses it for subsequent calls within the configured cacheLife period. It replaces the deprecated unstable_cache API and the old per-fetch { next: { revalidate } } pattern.
How do I enable 'use cache' in Next.js 16?
Add cacheComponents: true to your next.config.ts file. Without this flag, the 'use cache' directive is silently ignored and no caching occurs. If you were previously using the experimental.dynamicIO or experimental.useCache flags from the Next.js 15 canary releases, remove them those flags are now deprecated in 16.
What is the difference between 'use cache', 'use cache: remote', and 'use cache: private'?
'use cache' stores entries in server-side in-memory LRU storage fast, no cost, but doesn't persist across serverless function instances. 'use cache: remote' delegates to an external cache handler like Redis or Vercel KV, enabling cross-instance persistence at the cost of a network roundtrip and platform fees. 'use cache: private' (experimental) stores results only in the browser's memory, never on the server, and uniquely allows direct access to runtime APIs like cookies() and headers() inside the cached scope.
How do I migrate from unstable_cache to 'use cache'?
Replace the unstable_cache wrapper with a plain async function that has 'use cache' as its first statement, then add cacheLife() with the equivalent revalidate time, and cacheTag() for any tags you were using. The major difference is that the new directive automatically captures closure variables as part of the cache key you no longer need to manually specify keyParts. For large migrations, use the official codemod: npx @next/codemod@latest use-cache-upgrade . and audit every TODO comment it leaves.
Can I use cookies() or headers() inside a 'use cache' function?
No. not with the standard 'use cache' or 'use cache: remote' directives. Accessing runtime APIs inside a cached scope will throw a build error. The correct pattern is to read cookies() or headers() in the parent component (outside the cache scope) and pass the specific values you need as function arguments. If restructuring this way isn't practical, the experimental 'use cache: private' directive does allow runtime API access, but caches results only in the browser.
Why is my cache not persisting between requests on Vercel?
In serverless environments, in-memory cache entries don't persist across function invocations. Each cold-started instance has an empty in-memory cache. The 'use cache' directive still provides request-level deduplication and informs Next.js what content is prefetchable, but for true cross-request persistence in serverless deployments, you need 'use cache: remote' backed by a persistent store like Vercel KV or Redis.
Is the 'use cache' directive stable in Next.js 16?
Yes. 'use cache', cacheLife, cacheTag, and updateTag are all stable as of Next.js 16.2. The unstable_ prefix is gone. The exception is 'use cache: private', which remains experimental as of June 2026 and is not recommended for production-critical paths.
Continue Reading
Explore All ArticlesLevel Up Your Workflow
Free professional tools mentioned in this article
Tailwind Bento Grid Builder
Build responsive Tailwind CSS Bento Grid layouts visually drag, resize, and export clean React JSX or HTML code instantly. No coding needed.
CSS to Tailwind CSS Converter
Convert legacy CSS to modern Tailwind CSS utility classes instantly. 100% secure, free, and runs entirely in your browser. Boost your core web vitals today.
Image to WebP Converter
Convert up to 25 JPG, PNG, TIFF and BMP images to WebP with live before/after preview, smart resizing, and real-time savings metrics 100% client-side, nothing uploaded.
SVG to JSX / TSX Converter
Transform raw SVG code into production-ready React (JSX/TSX) components. Features camelCase mapping, Tailwind support, and TypeScript prop generation.



