cd /news/developer-tools/next-js-16-use-cache-directive-granu… · home › topics › developer-tools › article
[ARTICLE · art-161381] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Next.js 16 `use cache` Directive: Granular Caching at the Function Level and What It Replaces

Next.js 16 replaces its implicit caching model with an explicit `use cache` directive applied at the function and component level, paired with `cacheLife()` for expiration and `cacheTag()` for on-demand invalidation. The release removes `unstable_cache`, fetch cache options like `next.revalidate`, and compiler-inferred route segment caching, unifying database queries, fetch calls, and React Server Components under one caching syntax. A `use cache: remote` variant moves entries to a shared durable handler for higher hit rates.

by read16 min views1 publishedOct 31, 2026

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.

unstable_cache, fetch cache options, and all implicit caching; every cache decision is now explicit.cacheLife() sets expiration windows; cacheTag() enables on-demand invalidation; both pair with use cache: remote moves entries to a shared durable handler for high hit rates.next.revalidate with 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.

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.

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.

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();
}
js
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

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 orrevalidateTag() 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 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.

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.

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.

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.

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.

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.

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @next.js 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/next-js-16-use-cache…] indexed:0 read:16min 2026-10-31 · —