Next.js 16 `use cache` Directive: Granular Caching at the Function Level and What It Replaces
Next.js 16 use cache Directive: Granular Caching at the Function Level and What It Replaces This article was written with the assistance of AI, under human supervision and review. Next.js 16 eliminates the framework
Next.js 16 use cache Directive: Granular Caching at the Function Level and What It Replaces
This article was written with the assistance of AI, under human supervision and review.
Next.js 16 eliminates the framework's most confusing aspect: implicit caching. Previous versions cached fetch requests automatically, left developers guessing when route segments became static, and buried cache configuration inside fetch options. The new use cache directive makes every cache decision explicit. Developers mark what to cache, set expiration with cacheLife(), and invalidate with cacheTag().
The old model failed because developers could not predict cache behavior. A fetch call with no options might cache forever or not cache at all, depending on whether the route contained dynamic functions. Route segments became static or dynamic based on heuristics the compiler inferred from your code. Production bugs emerged when a component that worked dynamically in development suddenly rendered statically in production, serving stale data for hours.
Problem: implicit caching causes unpredictable cache behavior and stale data
Next.js 16 solves this with use cache. When you add the directive to a function or component, the framework caches its return value. When you call cacheLife('hours'), the cache expires in one hour. When you call cacheTag(['products']), you can invalidate that cache entry on demand. The mental model becomes: mark what you cache, state when it expires, name it for invalidation.
Solution: explicit use cache directive gives predictable control
This post covers what use cache replaces, how function-level and component-level caching work, migration patterns from unstable_cache, the cache hierarchy Next.js now uses, and production gotchas around cache storage.
Key Takeaways
-
use cachereplacesunstable_cache, fetch cache options, and all implicit caching; every cache decision is now explicit. - You can cache at the function level (data fetching) or component level (entire UI trees), with arguments and closures becoming part of the cache key automatically.
-
cacheLife()sets expiration windows;cacheTag()enables on-demand invalidation; both pair withuse cacheto control freshness. - Cache storage defaults to in-memory per instance;
use cache: remotemoves entries to a shared durable handler for high hit rates. - Migration requires converting
unstable_cachewrappers touse cachedirectives and replacing fetchnext.revalidatewithcacheLife()calls.
What use cache Replaces: unstable_cache, fetch Options, and Implicit Caching
use cache supersedes three caching mechanisms from Next.js 15 and earlier.
unstable_cache: developers wrapped functions in unstable_cache(fn, keyParts, options) to cache database queries or API calls. The wrapper returned a new function that memoized results. The name signaled instability; the API never graduated to stable. Next.js 16 removes it entirely.
fetch cache options: developers passed { next: { revalidate: 3600 } } to fetch calls to cache HTTP responses. The framework intercepted fetch, stored the response in a data cache, and served cached data until expiration. This worked only for fetch calls. Database queries and third-party SDK calls required unstable_cache.
Implicit route caching: Next.js analyzed route segments at build time. If a segment contained no dynamic functions (cookies, headers, searchParams), the compiler marked it static and prerendered it. If any child component called a dynamic function, the entire segment became dynamic. Developers could not see this decision; they inferred it from build output or production behavior.
All three mechanisms are gone. use cache handles every scenario.
Diagram showing old caching mechanisms replaced by use cache
The removal creates a single model. You mark cacheable operations with use cache, set expiration with cacheLife(), and invalidate with cacheTag(). The compiler generates cache keys from function arguments and captured closures. No wrapper functions, no fetch-specific options, no implicit heuristics.
This unification matters because developers now reason about caching the same way everywhere. A database query, a fetch call, and a React Server Component all use identical syntax. The cache key derivation follows the same rules. The expiration model applies uniformly.
How use cache Works: Function-Level and Component-Level Caching
The use cache directive applies to async functions and React Server Components. When Next.js encounters it, the framework serializes the function's return value into an RSC payload and stores that payload in a cache.
Function-level caching targets data operations. Add use cache to the top of an async function that fetches from a database, calls a third-party API, or performs expensive computation:
import { cacheLife } from 'next/cache';
export async function getProduct(id: string) {
'use cache';
cacheLife('hours');
const product = await db.query(
'SELECT * FROM products WHERE id = $1',
[id]
);
return product;
}
The compiler generates a cache key from the function identity and its arguments. In this example, the key includes getProduct and the id value. Calling getProduct('123') creates one cache entry; calling getProduct('456') creates another. The framework does not cache the function itself; it caches the result for each unique set of arguments.
Component-level caching targets UI trees. Add use cache to an async component that renders JSX:
import { cacheLife } from 'next/cache';
export default async function ProductList() {
'use cache';
cacheLife('minutes');
const products = await db.query('SELECT * FROM products');
return (
<ul>
{products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
The framework caches the entire RSC payload for the component. The payload includes the rendered JSX, serialized props, and any data the component loaded. When Next.js renders the same component again within the cache window, it returns the cached payload without executing the component body.
Content image showing cache key generation and payload storage
How use cache generates keys and stores payloads
The cache key includes arguments and any values captured from parent scopes. If a component reads a variable from a closure, that variable becomes part of the key. This ensures different inputs produce separate cache entries.
Arguments and return values must be serializable. You cannot cache functions, class instances with methods, or objects containing Symbols. The framework serializes primitives, plain objects, arrays, and React elements. Attempting to cache non-serializable values throws a runtime error.
Components marked with use cache cannot directly call cookies(), headers(), or access searchParams. These APIs provide request-time data that varies per request. Calling them inside a cached function creates a contradiction: the cache expects stable inputs, but runtime APIs return dynamic values. If you need runtime data, pass it as an argument to the cached function from a non-cached parent component.
Using cacheLife() for Granular Cache Control
cacheLife() sets the expiration policy for a cached function. Without it, Next.js applies a default profile. The default provides basic time-based invalidation, but production apps need explicit control.
The function accepts a named profile or a custom configuration object. Next.js 16 ships with four built-in profiles:
| Profile | Revalidate | Expire | Stale |
|---|---|---|---|
seconds |
15 seconds | 15 seconds | 1 minute |
minutes |
5 minutes | 5 minutes | 1 hour |
hours |
1 hour | 1 hour | 1 day |
days |
1 day | 1 day | 7 days |
Revalidate determines when the cache entry becomes stale. After this window, Next.js marks the entry for refresh but continues serving the stale value while fetching a new one in the background.
Expire sets the hard limit. After this window, the cache entry becomes invalid. Requests cannot use it; Next.js must fetch fresh data synchronously.
Stale controls client-side caching. The browser keeps the RSC payload fresh for this duration when prefetching or navigating.
Use a built-in profile when the default windows fit your data freshness requirements:
import { cacheLife } from 'next/cache';
export async function getUser(id: string) {
'use cache';
cacheLife('hours');
return await db.users.findUnique({ where: { id } });
}
Use a custom configuration when you need precise control:
import { cacheLife } from 'next/cache';
export async function getInventory(warehouseId: string) {
'use cache';
cacheLife({
revalidate: 300, // 5 minutes
expire: 600, // 10 minutes
stale: 3600, // 1 hour
});
return await inventoryApi.getStock(warehouseId);
}
The configuration accepts seconds as numbers. The example caches inventory for 5 minutes, expires it after 10 minutes, and allows the browser to use stale data for 1 hour during navigation.
Pairing cacheLife() with cacheTag() enables on-demand invalidation:
import { cacheLife, cacheTag } from 'next/cache';
export async function getProduct(id: string) {
'use cache';
cacheLife('hours');
cacheTag('products', `product-${id}`);
return await db.products.findUnique({ where: { id } });
}
When you call revalidateTag('products') from a Server Action, Next.js invalidates all cache entries tagged with products. The next request for any product executes the function and generates a fresh cache entry.
Migration Patterns: Converting Legacy Caching to use cache
Migrating from Next.js 15 caching requires three steps: remove unstable_cache wrappers, replace fetch cache options, and add explicit use cache directives.
Step 1: Remove unstable_cache wrappers
Old pattern with unstable_cache:
import { unstable_cache } from 'next/cache';
const getCachedUser = unstable_cache(
async (id: string) => {
return await db.users.findUnique({ where: { id } });
},
['user'],
{ revalidate: 3600, tags: ['users'] }
);
export async function getUser(id: string) {
return await getCachedUser(id);
}
New pattern with use cache:
import { cacheLife, cacheTag } from 'next/cache';
export async function getUser(id: string) {
'use cache';
cacheLife('hours');
cacheTag('users');
return await db.users.findUnique({ where: { id } });
}
The directive goes inside the function body. The cache key now derives automatically from the function identity and arguments. You do not specify key parts manually.
Step 2: Replace fetch cache options
Old pattern with fetch options:
export async function getProducts() {
const response = await fetch('https://api.example.com/products', {
next: { revalidate: 300, tags: ['products'] }
});
return response.json();
}
New pattern with use cache:
import { cacheLife, cacheTag } from 'next/cache';
export async function getProducts() {
'use cache';
cacheLife('minutes');
cacheTag('products');
const response = await fetch('https://api.example.com/products');
return response.json();
}
The fetch call no longer carries cache options. The use cache directive controls caching for the entire function, including the fetch call and any subsequent processing.
Content image showing migration from old caching patterns to use cache
Step 3: Add explicit directives to previously implicit routes
Routes that relied on implicit static optimization need explicit use cache directives. If a page component performs data fetching that should cache, add the directive:
import { cacheLife } from 'next/cache';
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
'use cache';
cacheLife('hours');
const { id } = await params;
const product = await db.products.findUnique({ where: { id } });
return <div>{product.name}</div>;
}
The migration flow moves all cache control to the function level. You see caching decisions in the code where they apply, not in build output or framework internals.
Migration flow from old caching patterns to use cache
use cache vs Data Cache vs Full Route Cache: Understanding the Cache Hierarchy
Next.js 16 maintains three cache layers: function-level cache (via use cache), prerendered HTML, and browser cache. Each serves a different purpose in the request lifecycle.
Function-level cache stores RSC payloads for individual functions and components. When you mark a function with use cache, Next.js caches its serialized return value. This cache applies during rendering. If a component calls a cached function multiple times in a single request, the framework returns the cached result after the first call.
Prerendered HTML stores complete pages generated at build time or during incremental static regeneration. When Next.js prerenders a page, it executes all cached functions, generates the RSC payload, renders HTML, and writes the HTML to disk. Subsequent requests serve the HTML directly without executing Server Components. The cacheLife expire window controls when the HTML becomes stale and triggers a background revalidation.
Browser cache stores RSC payloads the client receives during navigation or prefetching. When a user navigates to a new page, Next.js sends the RSC payload. The browser caches this payload according to the cacheLife stale window. During that window, navigating back to the page uses the cached payload without a server request.
Cache hierarchy showing function-level, HTML, and browser caches
These layers work together. A request hits the browser cache first. If the cached payload is stale, the browser requests from the server. The server checks for prerendered HTML. If the HTML is stale or missing, Next.js executes the page component. The component calls cached functions, which check the function-level cache. If a function's cached result is stale, the framework executes the function and updates the cache.
The distinction matters when you configure cacheLife. Setting revalidate to 5 minutes means the function-level cache refreshes every 5 minutes. Setting expire to 10 minutes means the prerendered HTML becomes invalid after 10 minutes. Setting stale to 1 hour means the browser uses its cached payload for 1 hour without checking the server.
| Cache layer | Storage location | Invalidation trigger | Controlled by |
|---|---|---|---|
| Function-level | In-memory or remote handler |
revalidate window or revalidateTag()
|
cacheLife() revalidate
|
| Prerendered HTML | Disk or platform storage |
expire window or deployment |
cacheLife() expire
|
| Browser | Client memory |
stale window or user action |
cacheLife() stale
|
The default cache handler stores function-level entries in memory per instance. This works for small apps but does not share cache across instances. For apps with multiple servers or serverless functions, use use cache: remote to store entries in a shared handler. The remote handler requires a network roundtrip for each cache hit, so it only pays off when the hit rate is high and the cached operation is expensive.
Production Gotchas: Cache Storage, Edge Runtime, and Common Mistakes
Production environments introduce cache storage constraints, edge runtime limitations, and developer mistakes that do not surface in local development.
Cache storage defaults to ephemeral in-memory per instance. When you deploy to a platform like Vercel, each serverless function instance maintains its own cache. Cache entries do not persist across cold starts. If an instance scales down and later scales up, the new instance starts with an empty cache. This differs from development, where next dev maintains a single long-running process with a persistent in-memory cache.
For durable shared caching, configure a cache handler in next.config.ts:
const nextConfig = {
cacheHandlers: {
default: {
handler: './cache-handler.js',
},
},
};
export default nextConfig;
The cache handler must implement the Next.js cache handler interface. The official Redis handler provides a reference implementation. Using a remote cache introduces latency; each cache hit requires a network roundtrip to the cache store. Profile your cache hit rate before migrating. If the hit rate is low, the network overhead exceeds the benefit.
Edge runtime does not support all cache operations. Functions deployed to the edge can use use cache, but they cannot write to a durable cache handler that requires a persistent connection. The edge runtime allows only stateless HTTP requests. If your cached function must run at the edge, limit cache storage to the edge instance's memory. Cache entries do not persist across edge locations or instance restarts.
Common mistakes include caching non-serializable values, accessing runtime APIs inside cached functions, and misunderstanding cache key derivation.
Attempting to cache a class instance fails at runtime:
// This crashes when Next.js tries to serialize the cache entry
export async function getUserService(id: string) {
'use cache';
return new UserService(id);
}
The framework can only serialize plain objects, arrays, primitives, and React elements. Extract the data from the class instance and cache the data:
export async function getUserData(id: string) {
'use cache';
const service = new UserService(id);
return await service.getData();
}
Accessing cookies() inside a cached function throws an error:
// This crashes because cookies() is a runtime API
export async function getSessionUser() {
'use cache';
const cookieStore = await cookies();
const sessionId = cookieStore.get('session');
return await db.sessions.findUnique({ where: { id: sessionId.value } });
}
Runtime APIs provide request-specific data. Caching request-specific data breaks the cache model. Move the runtime API call to a parent component and pass the value as an argument:
export async function Page() {
const cookieStore = await cookies();
const sessionId = cookieStore.get('session')?.value;
if (!sessionId) {
return <div>Not logged in</div>;
}
const user = await getCachedUser(sessionId);
return <div>Hello {user.name}</div>;
}
async function getCachedUser(sessionId: string) {
'use cache';
cacheLife('minutes');
const session = await db.sessions.findUnique({
where: { id: sessionId },
include: { user: true }
});
return session?.user;
}
The cached function receives sessionId as an argument. Different session IDs produce different cache entries. The cache key includes the session ID, so each user gets their own cached entry.
Production flow showing cache handler, edge limitations, and error paths
Cache key derivation includes function identity, arguments, and captured closures. If a closure captures a value from a parent scope, that value becomes part of the key. This can produce unintended cache misses if the captured value changes frequently:
export default async function ProductList({ category }: { category: string }) {
const baseUrl = process.env.API_URL;
// The cache key includes both category and baseUrl
async function fetchProducts() {
'use cache';
const response = await fetch(`${baseUrl}/products?category=${category}`);
return response.json();
}
return <div>{await fetchProducts()}</div>;
}
If API_URL changes between deployments, the cache key changes and all entries become invalid. For stable cache keys, pass environment variables as arguments or read them inside the cached function without capturing them from closures.
Frequently Asked Questions
When should you use use cache: remote instead of the default in-memory cache?
Use use cache: remote when your app runs on multiple serverless instances or containers and the cached operation is expensive enough to justify a network roundtrip. The remote cache shares entries across instances, but each cache hit requires fetching from the remote store. Profile your cache hit rate first; if it is below 80%, the network overhead likely exceeds the benefit.
Can you cache Server Actions with use cache?
Server Actions cannot use use cache directly because they are not idempotent operations; they mutate state rather than fetch data. You can pass Server Actions through cached components to Client Components without invoking them inside the cacheable function. The cached component treats the action as a serializable prop.
What happens when you call revalidateTag() with a tag that does not exist?
Calling revalidateTag() with a non-existent tag succeeds silently without invalidating anything. The framework does not track which tags exist; it only invalidates cache entries that match the tag. If no entries match, the call has no effect. This makes tag-based invalidation safe to call speculatively.
How does Next.js handle cache entries when you redeploy your app?
A new deployment generates a new build ID, which becomes part of the cache key for all use cache entries. When the build ID changes, all previous cache entries become inaccessible. This applies to both in-memory and remote caches. The new deployment starts with an empty cache, even if you use a durable cache handler.
Can you use use cache in Client Components?
Client Components cannot use use cache because they run in the browser, which has no server-side cache. The directive only works in Server Components and server-side async functions. If you need to cache data in a Client Component, fetch it in a Server Component, cache the Server Component with use cache, and pass the data as props.
Conclusion: Building a Mental Model for Next.js 16 Caching
The mental model for Next.js 16 caching reduces to three principles: mark what you cache, state when it expires, name it for invalidation. Every cache decision is explicit. No framework heuristics infer whether a route should cache. No fetch calls carry hidden cache options. No compiler analysis determines static versus dynamic rendering.
Add use cache to functions that fetch data or render UI. Call cacheLife() to set expiration windows. Call cacheTag() to enable on-demand invalidation. The compiler generates cache keys from function identity, arguments, and captured closures. Arguments and return values must be serializable. Runtime APIs like cookies() cannot be called inside cached functions.
Migration from Next.js 15 requires removing unstable_cache wrappers, replacing fetch cache options, and adding explicit directives. Production deployments need cache handler configuration for durable shared caching. Edge deployments limit cache to in-memory per instance. Cache entries do not persist across deployments because the build ID becomes part of the cache key.
The result is a caching model where developers control every aspect. You see caching decisions in the code where they apply. You reason about cache behavior the same way everywhere: functions, components, routes. You configure freshness requirements once, at the function level, rather than in multiple places with conflicting options. For teams managing complex data dependencies, this clarity eliminates an entire class of production bugs.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.







