Skip to content
Chapter 32Lesson 3

The use cache directive

Next.js 16's 'use cache' directive: its three placements, the cache key, the serialization contract, and pass-through for dynamic children.

Here is a Server Component you already know how to write. It fetches one marketing post from your CMS and renders it.

app/(marketing)/[slug]/post.tsx
export async function MarketingPost({ slug }: { slug: string }) {
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}

This is about as cacheable as anything in your app gets. The /pricing page reads the same row for every visitor, and that row changes only when an editor publishes an edit. Yet under the dynamic-by-default model, this component re-renders on every request, so every visitor pays for a fresh CMS read of content that hasn’t moved.

You can write this component and the getPost fetcher behind it. What you can’t yet do is tell Next.js to store the result and reuse it across requests instead of recomputing it for everyone. That is what 'use cache' is for. This lesson explains it in full: how to add it to a page, a component, or a plain function, what key your result is stored under, and which values are allowed to cross into and out of a cached scope.

'use cache' is one directive with three placements, and the placement you pick decides what gets stored. There is no separate “page cache” and “function cache” with different rules: it’s the same directive every time, and the scope it sits at is the scope it caches.

This puts it in the same family as the two directives you already know. 'use client' at the top of a module changes that module’s environment, 'use server' at the top of a function marks it as a Server Action, and 'use cache' at the top of a scope makes that scope’s output cacheable. Each is a string literal at the start of a scope that changes what the scope means.

The three tabs below show the three placements.

app/blog/page.tsx
'use cache';
export default async function Page() {
const posts = await listPosts();
return <PostList posts={posts} />;
}

At the top of a module, 'use cache' caches every export: a whole route segment from a page.tsx, or a whole data-layer module of fetchers. Every export of the file must be an async function.

The docs sort these by intent: caching a fetcher is “data-level” (the function placement), while caching a component or page is “UI-level” (the component and file placements).

A real cached function usually declares two more things: how long the entry lives, and a tag the system uses to invalidate it. Both belong to the next lesson; everything here works without them, on a sensible default lifetime.

Fix in your mind where the directive physically goes. The next exercise gives you a fetcher and a component with the directive missing, and asks you to pick the line it belongs on.

The directive is the first statement inside the scope you want cached — the first line of a fetcher's body, and the first line of a component's body, never before export. Pick the exact token for each blank. Pick the right option from each dropdown, then press Check.

// lib/invoices.ts — the fetcher
export async function getInvoiceSummary(orgId: string) {
___
const rows = await db.query.invoices.findMany({
where: eq(invoices.orgId, orgId),
});
return summarize(rows);
}
// app/billing/summary.tsx — the component
export async function InvoiceSummary({ orgId }: { orgId: string }) {
___
const summary = await getInvoiceSummary(orgId);
return <SummaryCard data={summary} />;
}

The cache key: what makes two calls share an entry

Section titled “The cache key: what makes two calls share an entry”

A cache is only useful if you can tell when two calls hit the same stored value and when they don’t. That decision belongs to the cache key . You never write a key by hand, the compiler builds it, but you do have to predict it: predict it wrong and you serve every user the same dashboard, or cache nothing at all.

Start with the simplest case, a cached function that takes no arguments.

export async function getFeaturedPlan() {
'use cache';
return db.query.plans.findFirst({ where: eq(plans.featured, true) });
}

This produces exactly one entry. The first call, from any request and any user, computes the result and stores it; every later call serves that stored value. That is the baseline: compute once, then reuse with no per-request work.

Add an argument by going back to getPost(slug). The moment a cached function takes a parameter, the compiler keys the entry by the serialized arguments.

export async function getPost(slug: string) {
'use cache';
return db.query.posts.findFirst({ where: eq(posts.slug, slug) });
}

getPost('pricing') and getPost('about') are now two separate entries, computed independently. But two callers that both pass 'pricing' share one entry, so the second serves what the first stored. Distinct arguments produce distinct entries automatically, with no key, map, or if statement on your part. That is what makes one cached fetcher safe to call from a dozen components: every caller passing the same slug collapses onto one stored result.

The argument is the part you control most directly, but the key the compiler hashes is built from four ingredients:

  1. The build ID, a value that changes on every deploy. A new deployment changes it, so no old key can match and the whole cache clears on every ship, for free.
  2. The function ID, a hash of the function’s location and signature. Edit the function’s code and its ID changes, so its old entries no longer match, and fixing a bug automatically invalidates the values that bug produced.
  3. The serializable arguments, meaning the function’s arguments or a component’s props.
  4. A dev-only hot-reload hash, so editing during development never serves stale results. You’ll never think about this one in production.

So far the key is code identity plus the arguments you pass. The next part is widely misstated online.

Closures are captured into the key, not forbidden. When a cached function reads a variable from an outer scope, Next.js captures that variable and binds it as if it were an argument, so it joins the key exactly like a real argument. You will read that cached functions “can’t use closures” or “can’t capture outer variables”; that is backwards, and following it pushes you toward more awkward code than you need.

In the walkthrough below, an outer component receives a userId, and an inner cached function takes a filter argument while also reading userId from the closure. Watch what ends up in the key.

export async function Dashboard({ userId }: { userId: string }) {
async function getData(filter: string) {
'use cache';
return db.query.events.findMany({
where: and(eq(events.userId, userId), eq(events.kind, filter)),
});
}
return <Events rows={await getData('errors')} />;
}

The outer Dashboard component receives userId. It is an ordinary Server Component taking a prop, and it is not cached itself.

export async function Dashboard({ userId }: { userId: string }) {
async function getData(filter: string) {
'use cache';
return db.query.events.findMany({
where: and(eq(events.userId, userId), eq(events.kind, filter)),
});
}
return <Events rows={await getData('errors')} />;
}

Inside it, a cached fetcher takes a filter argument, which will be part of the key as you’ve already seen.

export async function Dashboard({ userId }: { userId: string }) {
async function getData(filter: string) {
'use cache';
return db.query.events.findMany({
where: and(eq(events.userId, userId), eq(events.kind, filter)),
});
}
return <Events rows={await getData('errors')} />;
}

The directive. From here, getData is cached across requests.

export async function Dashboard({ userId }: { userId: string }) {
async function getData(filter: string) {
'use cache';
return db.query.events.findMany({
where: and(eq(events.userId, userId), eq(events.kind, filter)),
});
}
return <Events rows={await getData('errors')} />;
}

getData uses userId, which it never received as an argument; it grabbed it from the enclosing scope. Next.js captures userId and folds it into the key right alongside filter, so the key for this entry includes both values: the one passed in and the one captured. Two users with the same filter get two different entries because their userId differs, which is exactly what you want.

1 / 1

This is the model for the whole topic. The key is not just the arguments; it is the function’s code identity plus every serializable input the body touches, whether you passed it in or the function reached out and grabbed it from the surrounding scope. Both count equally: the compiler doesn’t care how the value got there, only that the cached body depends on it.

key ingredients

build id
function id
arguments
captured vars
fold into

cache key

hash(…)

looks up different args
different entries
getPost('pricing') entry A
stored render
getPost('about') entry B
stored render
The key is a composition: build id, function id, arguments, and captured vars fold into one hash. Two calls with different arguments produce different keys, so each is stored as its own entry.

The same model explains an edge case you must handle on purpose. A cached function is computed once and frozen under its key, so anything non-deterministic inside the scope freezes too. Date.now(), Math.random(), and crypto.randomUUID() each run once, at build or on the first call, and every later request gets that frozen value. The timestamp you thought was “now” is really “whenever this entry was first computed.”

This is the model working as designed, not a flaw: closure capture is a feature right up until you capture something that was supposed to change. When that happens, you have two choices:

  • You want a fresh value per request. Then it doesn’t belong in the cached scope. Defer it: call await connection() (from the first lesson of this chapter) before the non-deterministic work, and wrap that component in <Suspense> so it streams as a dynamic hole. The fresh value now lives outside the cache.
  • A shared, occasionally refreshed value is fine. Then leave it in the cached scope and accept that everyone sees the same value until the entry refreshes. A “trending this week” timestamp doesn’t need to be per-request.

Either choice is legitimate; the point is that Cache Components forces the choice into the open instead of leaving it to chance.

Two different users in the same org each load a dashboard in their own request. Both renders call the cached listInvoices(orgId) with the same orgId. How many times does the database query inside that fetcher actually run?

Once, then it stays put until the entry is refreshed — whichever request arrives first computes and stores the rows, and the other reads that stored value.
Twice: a cache is scoped to a single render pass, so a second request always recomputes from scratch.
Twice: two separate requests are fully isolated and can never read each other’s cached data.
Zero: the rows are only ever produced during the build, never on a live request.

What can cross the boundary: the serialization contract

Section titled “What can cross the boundary: the serialization contract”

The key is built from a function’s inputs, so the next question is which inputs are allowed. The cache stores your arguments and return value somewhere and reloads them later, possibly in another process, possibly days later. Anything that crosses that boundary has to be serializable .

You met this rule at the server/client boundary in the chapter on Server and Client Components: props passed from a Server Component to a Client Component had to serialize to cross the wire. The cache boundary works the same way, a value leaving one process to be reconstructed in another, so it enforces the same contract.

The contract is asymmetric:

  • Arguments use the stricter serialization, the same one the server uses to send a React tree to the client.
  • Return values use the looser client serialization.
  • So you can return JSX but cannot accept it: a cached component can hand back a rendered tree, but cannot take one in through a parameter.

Allowed in both directions: primitives (string, number, boolean, null, undefined), plain objects, arrays, Date, Map, Set, and typed arrays / ArrayBuffer. Return values get one bonus, JSX elements.

Rejected: class instances, functions, Symbol, WeakMap, and WeakSet. The one that surprises people is URL: it looks like plain data, but it’s a class instance, so it doesn’t serialize. Pass the string and rebuild the URL inside. Temporal values, which you’ll use throughout this course, are the same: encode them as ISO strings at the edge and parse them back on the other side, the boundary discipline the project’s conventions already apply to the server/client wire.

Hover each parameter to check whether it can cross the boundary.

lib/invoices.ts
export async function buildInvoice(
customerId: string,
issuedAt: Date,
lineItems: { sku: string; qty: number }[],
source: URL,
db: Database,
) {
'use cache';
// ...
}

Passing something unserializable as an argument gives you a build error, not a runtime surprise: the compiler catches it before the code runs. The next two tabs show the canonical mistake and its fix.

app/customers/customer-card.tsx
export async function CustomerCard({ customer }: { customer: Customer }) {
'use cache';
return (
<div>
<h3>{customer.name}</h3>
<p>{customer.email}</p>
</div>
);
}

Build error. A class instance isn’t serializable, so the cache can’t store this argument or fold it into the key.

Sort each value into the bucket it belongs in.

Can it cross the cache boundary as an argument to a 'use cache' function? Sort each value into the right bucket. Allowed types serialize and can join the key; rejected types are class instances or functions and fail the build. Drag each item into the bucket it belongs to, then press Check.

Serializable (allowed) Crosses the boundary and joins the key
Rejected (build error) Class instance or function — fails the build
string
{ id, total } (plain object)
Date
Map
an array of plain rows
a Drizzle db client
a logger instance
URL
a () => void callback

Pass-through: how a cached shell wraps dynamic children

Section titled “Pass-through: how a cached shell wraps dynamic children”

The lesson on shells and holes showed a cached shell wrapping a dynamic child: a cached Header and chrome around a dynamic <OrgInvoices /> table. But JSX isn’t a valid argument to a cached component, so how did a cached shell receive a dynamic child at all?

The answer is pass-through . A cached component may receive non-serializable values (JSX children, compositional slots, even a Server Action) on one condition: it must never look inside them. It drops them into the tree it returns. Because the cached body never reads them, they can’t affect its output, so they stay out of the key and stay as dynamic as they already were.

The distinction is between receiving a value and inspecting it. Placing {children} in your returned JSX is fine: you’re positioning it, not reading it. Reading children.props, or calling a Server Action you were handed, inside the cached body would break the pattern, because now the cached output depends on something the key doesn’t capture.

app/dashboard/dashboard-shell.tsx
export async function DashboardShell({
header,
children,
}: {
header: ReactNode;
children: ReactNode;
}) {
'use cache';
const nav = await listNavLinks();
return (
<div className="dashboard">
<Sidebar links={nav} />
<header>{header}</header>
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
export default function Page() {
return (
<DashboardShell header={<OrgSwitcher />}>
<Suspense fallback={<TableSkeleton />}>
<OrgInvoices />
</Suspense>
</DashboardShell>
);
}

The shell itself is cached. Its chrome, the sidebar and layout, is the same for everyone and ships from the cache.

app/dashboard/dashboard-shell.tsx
export async function DashboardShell({
header,
children,
}: {
header: ReactNode;
children: ReactNode;
}) {
'use cache';
const nav = await listNavLinks();
return (
<div className="dashboard">
<Sidebar links={nav} />
<header>{header}</header>
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
export default function Page() {
return (
<DashboardShell header={<OrgSwitcher />}>
<Suspense fallback={<TableSkeleton />}>
<OrgInvoices />
</Suspense>
</DashboardShell>
);
}

These slots are non-serializable JSX. The shell only places them in the tree, never reads them. That’s pass-through: they stay out of the key and out of the cache.

app/dashboard/dashboard-shell.tsx
export async function DashboardShell({
header,
children,
}: {
header: ReactNode;
children: ReactNode;
}) {
'use cache';
const nav = await listNavLinks();
return (
<div className="dashboard">
<Sidebar links={nav} />
<header>{header}</header>
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
export default function Page() {
return (
<DashboardShell header={<OrgSwitcher />}>
<Suspense fallback={<TableSkeleton />}>
<OrgInvoices />
</Suspense>
</DashboardShell>
);
}

This is the shell’s own cached work, the read whose result is stored under the key. The slots play no part in it.

app/dashboard/dashboard-shell.tsx
export async function DashboardShell({
header,
children,
}: {
header: ReactNode;
children: ReactNode;
}) {
'use cache';
const nav = await listNavLinks();
return (
<div className="dashboard">
<Sidebar links={nav} />
<header>{header}</header>
<main>{children}</main>
</div>
);
}
// app/dashboard/page.tsx
export default function Page() {
return (
<DashboardShell header={<OrgSwitcher />}>
<Suspense fallback={<TableSkeleton />}>
<OrgInvoices />
</Suspense>
</DashboardShell>
);
}

The call site passes a dynamic table as children. It stays dynamic, wrapped in its own <Suspense>, streaming in as a hole. The shell stays cached.

1 / 1

This also resolves the rule from the first lesson of this chapter, that dynamic content can’t nest inside a cached scope. Nothing nests here: the child is passed through the cached shell, not rendered inside it. The shell only positions a sealed box that React fills in later, which is what lets one page be part cached and part dynamic.

You write the directive, and the runtime supplies the store. By default that store is an in-memory LRU on the server.

What “in memory on the server” means depends on how you deploy.

Two variants exist but aren’t taught here. 'use cache: remote' routes entries to a shared cross-instance store like Redis or a KV service, so every instance reads one cache. 'use cache: private' is the only variant allowed to read request APIs like cookies(); it caches per user in browser memory, is never stored on the server, and is still experimental in Next.js 16. This course does the opposite: pass request data in as arguments and let personalized-but-fresh content stream as a dynamic hole. Treat 'use cache: private' as a rarely needed escape hatch.

The takeaway: caching is storage, and storage has a budget, so cache the smallest useful slice rather than the whole page.

The pattern experienced teams settle on is simple: every read becomes a small 'use cache' function in your data layer, and components that need data import the fetcher and call it.

The cache is automatic and shared: every caller passing the same arguments collapses onto one stored entry, with no coordination required. A cached component can call cached functions, and the entries compose, so the shell caches its chrome and the fetcher caches its rows, each under its own key.

If you’ve seen older Next.js code, this is a deliberate inversion. Before version 16, a bare fetch() was cached by default, and you opted out with { cache: 'no-store' } for fresh data. Cache Components removes that implicit behavior, in line with the explicit-by-default rule from this chapter’s first lesson. Now you cache a read by wrapping it in a 'use cache' function, so the durable rule is: wrap to cache.

The two tabs below turn a component doing an inline fetch that re-runs every render into an imported cached fetcher.

app/blog/[slug]/page.tsx
export async function PostPage({ slug }: { slug: string }) {
const res = await fetch(`https://cms.example.com/posts/${slug}`);
const post = await res.json();
return <Article post={post} />;
}

The fetch lives inside the component, so it re-runs on every render, and nothing is stored.

The commented line, cacheLife + cacheTag → next lesson, marks where every real cached read will gain a lifetime and a tag, which you’ll add in the next lesson; for now the fetcher works as written.

A teammate’s pull request caches an invoices fetcher and ships the two mistakes this lesson works hardest to prevent. Leave a comment on each.

A teammate opened this PR to cache the invoices list per organization. The query itself is correct — review it against the two rules from this lesson and comment on every line that breaks one. Click any line to leave a review comment, then press Submit review.

lib/invoices.ts
import { cookies } from 'next/headers';
export async function listOrgInvoices(db: Database) {
'use cache';
const orgId = (await cookies()).get('org')?.value;
return db.query.invoices.findMany({
where: eq(invoices.orgId, orgId),
});
}

Five boundaries, each one a common mistake or a setup for a later lesson.

It’s not request-scoped. 'use cache' persists across requests and users; that’s its purpose. Deduplicating work within a single request, the kind that has to read request data (getCurrentUser() reads the session every request but shouldn’t run five times in one render), is a different tool: React’s cache(), two lessons from now. 'use cache' is cross-request storage; cache() is per-request memoization.

It’s not a hint. It’s a contract the compiler enforces at build. Capture something non-serializable or read a request API inside it, and you get a build error, not a silently slower path.

It’s not free. Every entry costs storage and can be evicted, so cache the smallest useful slice, not the whole world.

It’s not for client code. 'use cache' is server-only. It has no meaning in a 'use client' module, which runs in the browser, where there’s no shared server cache to write to.

It’s not the personalized or remote variants. 'use cache: private' and 'use cache: remote' are not the default. Plain 'use cache' is server-side, cross-request, and shared.