Skip to content
jsmanifest logojsmanifest

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

Next.js 16 replaces implicit caching with explicit use cache directives. Learn function-level caching, cacheLife profiles, migration from unstable_cache, and production cache storage patterns.

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.

%% alt: Problem: implicit caching causes unpredictable cache behavior and stale data
flowchart LR
    Request("Request arrives")
    Fetch("fetch call executes")
    Heuristic("Framework infers cache from heuristics")
    Stale("Stale data served for hours")
    Request --> Fetch --> Heuristic --> Stale
    style Stale stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
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.

%% alt: Solution: explicit use cache directive gives predictable control
flowchart LR
    Request("Request arrives")
    Directive("use cache directive encountered")
    Profile("cacheLife profile applies")
    Fresh("Fresh data served within TTL")
    Request --> Directive --> Profile --> Fresh
    style Fresh stroke:#34d399,fill:#0b3b2e,color:#d1fae5
    style Directive stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
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 cache replaces unstable_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 with use cache to control freshness.
  • Cache storage defaults to in-memory per instance; use cache: remote moves entries to a shared durable handler for high hit rates.
  • Migration requires converting unstable_cache wrappers to use cache directives and replacing fetch next.revalidate with cacheLife() 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.

%% alt: Diagram showing old caching mechanisms replaced by use cache
flowchart TD
    Old["Old caching mechanisms"]
    UnstableCache("unstable_cache wrapper")
    FetchOptions("fetch cache options")
    ImplicitRoute("Implicit route caching")
    UseCache("use cache directive")
    
    Old --> UnstableCache
    Old --> FetchOptions
    Old --> ImplicitRoute
    UnstableCache --> UseCache
    FetchOptions --> UseCache
    ImplicitRoute --> UseCache
    
    style Old stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
    style UseCache stroke:#34d399,fill:#0b3b2e,color:#d1fae5
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

%% alt: How use cache generates keys and stores payloads
flowchart TD
    Component("Component with use cache")
    Args("Arguments and closures")
    Key("Cache key generated")
    Execute("Function executes")
    Serialize("Return value serialized to RSC payload")
    Store("Payload stored with TTL")
    
    Component --> Args
    Args --> Key
    Key --> Execute
    Execute --> Serialize
    Serialize --> Store
    
    style Execute stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
    style Store stroke:#34d399,fill:#0b3b2e,color:#d1fae5
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:

ProfileRevalidateExpireStale
seconds15 seconds15 seconds1 minute
minutes5 minutes5 minutes1 hour
hours1 hour1 hour1 day
days1 day1 day7 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.

%% alt: Migration flow from old caching patterns to use cache
flowchart LR
    Old("Old caching code")
    Remove("Remove unstable_cache wrapper")
    Replace("Replace fetch options")
    Add("Add use cache directive")
    New("New explicit caching")
    
    Old --> Remove
    Remove --> Replace
    Replace --> Add
    Add --> New
    
    style Old stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
    style New stroke:#34d399,fill:#0b3b2e,color:#d1fae5
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.

%% alt: Cache hierarchy showing function-level, HTML, and browser caches
flowchart LR
    subgraph Server["Server-side caches"]
        Function("Function-level cache<br/>RSC payloads per function")
        HTML("Prerendered HTML<br/>Complete static pages")
    end
    
    subgraph Client["Client-side cache"]
        Browser("Browser cache<br/>RSC payloads for navigation")
    end
    
    Function --> HTML
    HTML --> Browser
    
    style Function stroke:#7c9cf0,fill:#142544,color:#eaf2ff
    style HTML stroke:#7c9cf0,fill:#142544,color:#eaf2ff
    style Browser stroke:#7c9cf0,fill:#142544,color:#eaf2ff
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 layerStorage locationInvalidation triggerControlled by
Function-levelIn-memory or remote handlerrevalidate window or revalidateTag()cacheLife() revalidate
Prerendered HTMLDisk or platform storageexpire window or deploymentcacheLife() expire
BrowserClient memorystale window or user actioncacheLife() 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.

%% alt: Production flow showing cache handler, edge limitations, and error paths
flowchart LR
    Request("Request arrives")
    CheckHandler("Check cache handler")
    InMemory("In-memory cache hit")
    Remote("Remote cache query")
    EdgeLimit("Edge runtime limits cache to memory")
    SerializeError("Serialization error on non-plain object")
    Success("Cache entry returned")
    
    Request --> CheckHandler
    CheckHandler --> InMemory
    CheckHandler --> Remote
    CheckHandler --> EdgeLimit
    CheckHandler --> SerializeError
    InMemory --> Success
    Remote --> Success
    
    style SerializeError stroke:#ef4444,fill:#450a0a,color:#fca5a5
    style EdgeLimit stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
    style Success stroke:#34d399,fill:#0b3b2e,color:#d1fae5
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.