
The "use cache" Directive in Next.js: A Guide to the New Caching Model
Caching in the App Router used to be something that happened to you. fetch calls were cached or not depending on the version, routes became static or dynamic based on which APIs you touched, and you tuned behavior with a mix of fetch options, unstable_cache, and exports like revalidate and dynamic. It worked, but it was hard to look at a component and say with confidence what was cached and for how long.
Next.js 16 introduces a different model. With Cache Components enabled, nothing is cached unless you say so, and you say so with a directive: "use cache". You put it at the top of a function, a component, or a file, and that unit's output is cached. Lifetimes are set with cacheLife, invalidation hooks with cacheTag, and everything else stays dynamic by default.
This guide covers how to enable the directive, where you can put it, how cache keys are built, how to control lifetimes and tags, how to deal with cookies and headers, and the variants "use cache: private" and "use cache: remote". It ends with the mistakes that most often cause build errors or surprising behavior.
Enabling Cache Components
"use cache" is part of the Cache Components feature. Turn it on in your Next.js config:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
This single flag replaces the experimental dynamicIO, useCache, and ppr flags from Next.js 15. It also changes some defaults, so it's worth knowing what you're opting into:
- Data fetching is dynamic by default. A
fetchor database call runs at request time unless it's inside a"use cache"scope. - Route segment configs go away.
export const dynamic,revalidate, andfetchCachecause errors;"use cache"andcacheLifereplace them. - Routes get a static shell. Next.js prerenders everything it can, including cached output, and streams the rest. That's Partial Prerendering, covered in its own post on combining static and dynamic content.
- The Node.js runtime is required. Routes using the deprecated
runtime = "edge"need to move.
If you're adding this to an existing app, the official migration guide walks through each old config and its replacement.
Three Places to Put the Directive
On a data function
The most common use is caching a function that fetches or computes data:
// lib/data/products.ts
import { cacheLife } from "next/cache";
export type Product = { id: string; name: string; price: number };
export async function getProducts(): Promise<Product[]> {
"use cache";
cacheLife("hours");
const res = await fetch("https://api.example.com/products");
if (!res.ok) throw new Error("Failed to load products");
return res.json();
}
The directive must be the first statement in the function body, and the function must be async. The first call runs the body and stores the result; later calls with the same inputs reuse it until it's revalidated.
This works for anything async, not just fetch. A database query, a call to an SDK, or an expensive computation can all be cached the same way, which is why "use cache" replaces unstable_cache.
On a component
You can cache a Server Component's rendered output:
// app/components/featured-products.tsx
import { cacheLife } from "next/cache";
import { getProducts } from "@/lib/data/products";
export async function FeaturedProducts() {
"use cache";
cacheLife("hours");
const products = await getProducts();
return (
<section>
<h2>Featured</h2>
<ul>
{products.slice(0, 4).map((product) => (
<li key={product.id}>
{product.name}: ${product.price}
</li>
))}
</ul>
</section>
);
}
Now the rendered JSX is cached, not just the data. Use this when the rendering itself is worth skipping, or when you want one clear caching decision for a whole section of the page.
On a file
Placing "use cache" at the very top of a module caches every exported function in it. Each export must be async:
// lib/data/reports.ts
"use cache";
import { cacheLife } from "next/cache";
export async function getMonthlyRevenue(accountId: string) {
cacheLife("days");
const res = await fetch(
`https://api.example.com/reports/${accountId}/revenue`,
);
return res.json();
}
export async function getTopCustomers(accountId: string) {
cacheLife("days");
const res = await fetch(
`https://api.example.com/reports/${accountId}/customers`,
);
return res.json();
}
A page.tsx or layout.tsx can use a file-level directive too. Each segment is cached independently, so caching a whole route means adding the directive to the page, its layouts, and any parallel route slots. A cached layout doesn't cache the children it renders; those pass through untouched.
How Cache Keys Work
You don't write cache keys with "use cache". Next.js builds them from:
- The build ID (or
deploymentId, if configured), so a new deployment starts with a fresh cache. - A function ID, a hash of where the function lives in your code.
- The serialized arguments, or props for a component.
- Captured variables from enclosing scopes, which are treated as extra arguments.
The practical upshot: different inputs get different entries.
// lib/data/products.ts
import { cacheLife, cacheTag } from "next/cache";
export async function getProduct(id: string) {
"use cache";
cacheLife("days");
cacheTag("products", `product:${id}`);
const res = await fetch(`https://api.example.com/products/${id}`);
if (!res.ok) return null;
return res.json() as Promise<{ id: string; name: string; price: number }>;
}
getProduct("1") and getProduct("2") are separate entries. Closures work the same way:
// app/products/[id]/reviews.tsx
export async function Reviews({ productId }: { productId: string }) {
async function getReviews(sort: "newest" | "top") {
"use cache";
// productId (captured) and sort (argument) both become part of the key
const res = await fetch(
`https://api.example.com/products/${productId}/reviews?sort=${sort}`,
);
return res.json() as Promise<{ id: string; text: string }[]>;
}
const reviews = await getReviews("top");
return (
<ul>
{reviews.map((r) => (
<li key={r.id}>{r.text}</li>
))}
</ul>
);
}
What can be an argument
Arguments and return values have to be serializable, because they're stored and compared:
- Arguments: primitives, plain objects, arrays,
Date,Map,Set, typed arrays. - Return values: the same, plus JSX.
- Not allowed: class instances, functions (except as pass-through), symbols,
URLinstances.
"Pass-through" is the interesting exception. A cached component can accept children, other JSX slots, or a Server Action as props, as long as it doesn't inspect them:
// app/components/cached-shell.tsx
import type { ReactNode } from "react";
import { cacheLife } from "next/cache";
export async function CachedShell({
sidebar,
children,
}: {
sidebar: ReactNode;
children: ReactNode;
}) {
"use cache";
cacheLife("days");
const res = await fetch("https://api.example.com/navigation");
const links: { href: string; label: string }[] = await res.json();
return (
<div className="layout">
<nav>
{links.map((link) => (
<a key={link.href} href={link.href}>
{link.label}
</a>
))}
</nav>
<aside>{sidebar}</aside>
<main>{children}</main>
</div>
);
}
The navigation data is cached, while whatever you pass as sidebar and children can be completely dynamic. The slots don't affect the cache key because the component never looks inside them. This interleaving pattern is how you cache a frame without caching its contents.
Controlling Lifetimes with cacheLife
Every "use cache" scope has a lifetime with three parts:
stale: how long the browser can reuse the result without asking the server. The client enforces at least 30 seconds.revalidate: after this, the next request gets the cached value and triggers a background refresh.expire: after this long with no requests, the next request waits for fresh data.
Built-in profiles cover most needs:
| Profile | stale | revalidate | expire |
|---|---|---|---|
default | 5 min | 15 min | never |
seconds | 30 s | 1 s | 1 min |
minutes | 5 min | 1 min | 1 hour |
hours | 5 min | 1 hour | 1 day |
days | 5 min | 1 day | 1 week |
weeks | 5 min | 1 week | 30 days |
max | 5 min | 30 days | 1 year |
Call cacheLife with a profile name inside the cached scope. If you don't call it, the default profile applies, but the Next.js docs recommend always being explicit so the behavior is readable at the call site.
You can define your own profiles in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
editorial: {
stale: 600, // 10 minutes
revalidate: 3600, // 1 hour
expire: 86400, // 1 day
},
},
};
export default nextConfig;
Then use cacheLife("editorial") anywhere. For one-off cases, pass an object inline: cacheLife({ revalidate: 900, expire: 86400 }). Omitted properties fall back to the default profile.
Short lifetimes and prerendering
Lifetimes also decide whether cached output can be part of the prerendered static shell. A cache with revalidate: 0 or an expire under five minutes (the seconds profile, for example) is too short-lived to bake into a prerender. It becomes a dynamic hole that runs at request time, and it should sit inside a Suspense boundary.
Nested caches
When one cached function calls another, an explicit cacheLife on the outer one wins. Without it, the outer cache uses the default profile, and a shorter inner lifetime can shrink it. If a short-lived cache (like seconds) is nested inside a cache with no explicit cacheLife, Next.js throws during prerendering rather than let the outer cache silently become short-lived. The fix is to set cacheLife on the outer scope.
Invalidating with cacheTag
Time-based lifetimes are only half of the story. For content that changes when someone edits it, tag the cache entry and invalidate on demand:
// app/admin/products/actions.ts
"use server";
import { updateTag } from "next/cache";
import { saveProduct } from "@/lib/admin";
export async function renameProduct(id: string, formData: FormData) {
await saveProduct(id, { name: String(formData.get("name") ?? "") });
updateTag(`product:${id}`);
}
updateTag expires the entry immediately so the editor sees the change; revalidateTag(tag, "max") refreshes it in the background, which suits webhooks. A common pairing is a long lifetime like cacheLife("max") plus tags, so content stays cached until it actually changes. The full comparison is in the post on on-demand revalidation.
Cookies, Headers, and Other Runtime Data
A cached function can't call cookies(), headers(), or read searchParams. That restriction follows the call stack, so a helper that reads a cookie fails too, with a next-request-in-use-cache error. The reason is simple: a shared cache entry built from one user's cookies would be served to everyone.
The preferred fix is to read the runtime value outside the cache and pass it in as an argument:
// app/account/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { cacheLife } from "next/cache";
export default function AccountPage() {
return (
<Suspense fallback={<p>Loading your recommendations...</p>}>
<Recommendations />
</Suspense>
);
}
async function Recommendations() {
const region = (await cookies()).get("region")?.value ?? "us";
return <RegionalPicks region={region} />;
}
async function RegionalPicks({ region }: { region: string }) {
"use cache";
cacheLife("hours");
const res = await fetch(`https://api.example.com/picks?region=${region}`);
const picks: { id: string; name: string }[] = await res.json();
return (
<ul>
{picks.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
Recommendations reads the cookie, so it runs per request inside a Suspense boundary. RegionalPicks is cached per region: every user in the same region shares one entry. You get personalization without giving up caching.
Two related rules:
- Don't pass runtime promises into a cache. Passing the unresolved
cookies()promise, or a promise for uncached data, as a prop makes the cached function wait on something that can't resolve at build time. The build hangs and eventually fails with a "Filling a cache during prerender timed out" error. Await first, then pass the plain value. React.cachedoesn't cross the boundary. Values stored withReact.cacheoutside a"use cache"scope aren't visible inside it. Use arguments instead.
Draft Mode is an exception: you can read (await draftMode()).isEnabled inside a cached scope, and when Draft Mode is on, cached functions re-run on every request without saving results.
"use cache: private" and "use cache: remote"
Two variants handle cases the plain directive doesn't.
"use cache: private" lets the function read cookies(), headers(), and searchParams directly. Results are never stored on the server; they're cached only in the browser's memory, which helps prefetching and back navigation. Reach for it when you can't refactor the runtime read out of the function, or when compliance rules prevent storing certain data on the server at all.
"use cache: remote" stores entries in a shared, durable cache handler instead of per-instance memory. That matters because the default "use cache" store is in-memory: on serverless platforms each instance has its own short-lived memory, so entries outside the static shell may rarely be reused. Remote caching costs a network lookup and usually storage fees, so it pays off for expensive or rate-limited upstreams with a high hit rate, not for fast queries or highly unique keys.
"use cache" | "use cache: remote" | "use cache: private" | |
|---|---|---|---|
| Stored on server | In memory (or a configured handler) | Remote cache handler | No |
| Shared between users | Yes | Yes | No |
| Can read cookies/headers | No, pass as arguments | No, pass as arguments | Yes |
| Extra cost | None | Storage and lookup latency | None |
Hosting platforms typically provide a remote handler. When self-hosting, configure one with the cacheHandlers option.
Migrating from the Old APIs
If you're moving an existing app over, most changes are mechanical:
| Before | After |
|---|---|
fetch(url, { cache: "force-cache" }) | Wrap the fetch in a "use cache" function |
next: { revalidate: 3600 } | cacheLife("hours") |
next: { tags: ["posts"] } | cacheTag("posts") |
unstable_cache(fn, keys, opts) | "use cache" in fn, plus cacheLife and cacheTag |
export const revalidate = 3600 | "use cache" and cacheLife in the page |
export const dynamic = "force-dynamic" | Remove it; dynamic is the default |
unstable_noStore() | Remove it; uncached is the default |
One difference to plan for: the fetch Data Cache and unstable_cache persist across deployments. "use cache" entries don't, because the build ID is part of every key. If you depended on long-lived cached data surviving deploys, that changes. For background on the older layers, see Understanding the Next.js caching layers.
Debugging
- Console output. In development, logs from inside cached functions are replayed with a
Cacheprefix, so you can tell a cache hit from a fresh run. - Verbose logs. Run with
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev(or withnpm run start) to see cache reads and writes. - Production builds. Test with
next build && next start. The build output marks partially prerendered routes with◐, which shows how much of each route ended up in the static shell.
Conclusion
"use cache" turns caching from an implicit side effect into a decision you write down. Mark a function, component, or file as cached; give it a lifetime with cacheLife; tag it with cacheTag if it should be invalidated on demand. Everything else stays dynamic, and Next.js prerenders as much of each route as your cached and static code allows.
Keep a few rules in mind: always set an explicit cacheLife, read cookies and headers outside the cache and pass plain values in, use pass-through slots to cache a frame without its contents, and remember that the default store is in-memory and scoped to a deployment. With those habits, you can look at any component and know exactly what's cached and for how long.


