The use cache directive
The full anatomy of the 'use cache' directive, the one explicit tool Next.js gives you to store a result and reuse it across requests, from its three placements to the cache key, the serialization contract, and how a cached shell wraps a dynamic child.
Here is a Server Component you already know how to write. It fetches one marketing post from your CMS and renders it.
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, perhaps once a week. Yet under the dynamic-by-default model from earlier in this chapter, this component re-renders on every request, so every visitor pays for a fresh CMS read of content that hasn’t moved.
You can already write this component and the getPost fetcher behind it. What you can’t yet do is tell Next.js to store that result and reuse it across requests instead of recomputing it for everyone. That is what 'use cache' is for. The first lesson of this chapter named the directive and promised its anatomy two lessons later, and the lesson on shells and holes leaned on it as an opaque “this belongs in the shell” marker. This is the lesson that explains it in full. By the end you’ll be able to add 'use cache' to a page, a component, or a plain function, predict the exact key your result is stored under, and know which values are allowed to cross into and out of a cached scope.
One directive, three placements
Section titled “One directive, three placements”The first idea to learn is structural, and it’s smaller than it looks. '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. All three are a string literal at the start of a scope that changes what the scope means.
The following three tabs show the three placements. Read them as variations on one idea, not as three separate features.
'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. The one constraint is that every export of the file must be an async function.
export async function PricingTable() { 'use cache'; const plans = await listPlans();
return <Plans plans={plans} />;}As the first line of an async component body, 'use cache' caches that component’s render output, and the component’s serialized props become part of the key.
export async function getPost(slug: string) { 'use cache'; const post = await db.query.posts.findFirst({ where: eq(posts.slug, slug), });
return post;}As the first line of a plain async function, 'use cache' caches that function’s return value. This is the data-fetcher case, and the one you’ll reach for most.
The official docs sometimes frame this as two uses rather than three: “data-level” caching (caching a fetcher) and “UI-level” caching (caching a component or page). That’s the same set of placements sorted by intent. The function placement is data-level, while the component and file placements are UI-level. So the three placements are what you actually type, and the data-versus-UI distinction is the reason you’d reach for each one.
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, so leave them off for now. Everything in this lesson works without them, because there’s a sensible default lifetime, and you’ll learn to set your own next.
Before going deeper, 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 fetcherexport 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 componentexport 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. What decides that is the cache key . You never write a key by hand; the compiler builds it for you. But you do have to predict it, because predicting it wrong is how you end up serving every user the same dashboard, or caching nothing at all.
Let’s build the key up one piece at a time, starting 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 anywhere, from any request and any user, computes the result and stores it, and every later call serves the stored value. That’s the baseline: cached output is computed once and reused, with no per-request work after the first.
Now 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, two stored values computed independently. But two different callers that both pass 'pricing' share one entry, so the second serves what the first one stored. Distinct arguments produce distinct entries automatically, with no key, map, or if statement on your part. This is what makes a single cached fetcher safe to call from a dozen different components: every caller passing the same slug collapses onto one stored result.
The argument is the part you control most directly, but it isn’t the whole story. The key the compiler hashes is built from four ingredients:
- The build ID, a value that changes on every deploy. This is why a new deployment wipes every entry: the build ID is different, so no old key can match. You get a clean cache on every ship, for free.
- 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. Fixing a bug in a cached function automatically invalidates the values that bug produced.
- The serializable arguments, meaning the function’s arguments or a component’s props.
- A dev-only hot-reload hash, so editing during development doesn’t serve you stale results. You’ll never think about this one in production.
So far the key is code identity plus the arguments you pass. Here is the part that trips people up, and where a lot of the writing you’ll find online is simply wrong.
Closures are captured into the key, not forbidden. When a cached function references a variable from an outer scope, Next.js automatically captures that variable and binds it as if it were an argument, so it becomes part of the cache key exactly like a real argument would. A lot of writing on 'use cache' claims that cached functions “can’t use closures” or “can’t capture outer variables.” That is wrong, and following it will push you toward more awkward code than you need. The truth is the opposite: the closure variable is folded into the key for you.
The next walkthrough shows the canonical case. An outer component receives a userId, and an inner cached function takes a filter argument and also reaches out to use 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. This is ordinary, just a 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. Nothing surprising yet: filter 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')} />;}Here’s the directive. From this point, 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.
This is the mental model that matters for the whole topic. The key isn’t just “the arguments.” It’s the function’s code identity plus every serializable input the function touches, whether you handed that input in as an argument 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; if the cached body depends on it, it’s in the key.
The following figure draws that whole model in one picture: the ingredients on the left compose into a single key, and two different argument values fan out to two distinct stored entries.
key ingredients
cache key
hash(…)
different entries
That model also explains an edge case you need to handle on purpose. A cached function is computed once and frozen under its key, so anything non-deterministic inside a cached scope gets frozen too. Date.now(), Math.random(), and crypto.randomUUID() each run once, at build or on the first call, and every later request gets that same frozen value. The timestamp you thought was “now” is actually “whenever this entry was first computed.”
This isn’t a flaw in the framework; it’s the model working as designed. Closure capture is a feature right up until you accidentally 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 is refreshed. 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?
orgId means the same key, and a 'use cache' entry is shared across requests and users — so the second render serves what the first one stored, and the query runs once until the entry is refreshed. The two “twice” answers are the trap: per-render isolation describes React’s cache(), a different tool you’ll meet two lessons from now, and cross-request reuse is the entire reason 'use cache' exists, so isolation is exactly backwards. “Zero” is wrong too — an entry that wasn’t prerendered is computed on its first live use, then reused.What can cross the boundary: the serialization contract
Section titled “What can cross the boundary: the serialization contract”You now know the key is built from a function’s inputs. The next question is which inputs are even allowed. The cache has to store your arguments and your return value somewhere and reload them later, possibly in a different process, possibly days later. Anything that crosses that boundary has to be serializable .
You’ve met this exact rule before. The server/client boundary from the chapter on Server and Client Components had the same requirement: props passed from a Server Component to a Client Component had to serialize to cross the wire. The cache boundary is the same kind of boundary, a value leaving one process to be reconstructed in another, so it enforces the same kind of contract. If the wire rules from that chapter stuck, you already know most of this.
There’s one twist worth slowing down for: 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.
- The practical consequence is that you can return JSX but cannot accept JSX as an argument. A cached component can hand back a rendered tree, but it cannot take one in through a normal parameter.
Here’s what’s 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.
Here’s what’s rejected: class instances, functions, Symbol, WeakMap and WeakSet, and the one that surprises everyone, URL instances. A URL looks like plain data, but it’s a class instance, so it doesn’t serialize. Pass the string and rebuild the URL inside. The same goes for Temporal values, which you’ll use throughout this course: they don’t cross the boundary, so the convention is to encode them as ISO strings at the edge and parse them back on the other side. That’s the same boundary discipline the project’s code conventions apply to the server/client wire.
The next signature labels each parameter type with whether it can cross the boundary. Hover each one to check.
export async function buildInvoice( customerId: string, issuedAt: Date, lineItems: { sku: string; qty: number }[], source: URL, db: Database,) { 'use cache'; // ...}When you do pass something unserializable as an argument, you get a build error rather than a runtime surprise: the compiler catches it before the code ever runs. The next two tabs show the canonical mistake and its fix.
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.
export async function CustomerCard({ id, name, email,}: { id: string; name: string; email: string;}) { 'use cache'; return ( <div> <h3>{name}</h3> <p>{email}</p> </div> );}Pass the plain data the component actually renders. The key is built from serializable values only, and that’s all this component needs.
Whether a value is serializable is the judgment people get wrong most often in real cached code, so it’s worth a drill. Sort each item 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.
string{ id, total } (plain object)DateMapdb clientlogger instanceURL() => void callbackPass-through: how a cached shell wraps dynamic children
Section titled “Pass-through: how a cached shell wraps dynamic children”If you read the last section carefully, something should be bothering you. 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 is allowed to receive non-serializable values (JSX children, compositional slots, even a Server Action) on one condition: it must never look inside them. It just drops them into the tree it returns. Because the cached body never reads them, they can’t affect its output, so they aren’t part of the key and aren’t cached. They flow straight through, untouched, 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, because you’re not reading it, only positioning 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.
The next walkthrough is the shell-with-a-hole from the earlier lesson, finally shown as code.
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.tsxexport 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.
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.tsxexport default function Page() { return ( <DashboardShell header={<OrgSwitcher />}> <Suspense fallback={<TableSkeleton />}> <OrgInvoices /> </Suspense> </DashboardShell> );}These slots are non-serializable JSX. The shell receives them but never reads them; it only places them in the tree. That’s pass-through: they aren’t in the key and they aren’t cached.
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.tsxexport 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 actually stored under the key. The slots play no part in it.
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.tsxexport 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.
This is the shell-with-a-hole from the earlier lesson, now visible as code. The cached shell carries 'use cache', while the dynamic child lives in its own <Suspense> and is handed in as a slot. It 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 dynamic child is passed through the cached shell, not rendered inside it. The shell never touches the child; it only positions a sealed box that React fills in later. That is what lets one page be part cached and part dynamic.
Where the cached value actually lives
Section titled “Where the cached value actually lives”It’s worth grounding all of this for a moment, because caching is real storage with real limits. You write a directive, and the runtime supplies the store. By default that store is an in-memory LRU on the server. Entries live in memory, and when memory runs short, the least recently used entries are evicted to make room.
“In memory on the server” has one consequence you need to know before it confuses you, and it depends on how you deploy.
There are two ways to get cross-instance persistence, and you should know the names without studying them yet. 'use cache: remote' routes entries to a dedicated cross-instance store like Redis or a KV service, so every instance shares one cache; when self-hosting, you configure it through a cache handler. These are pointers, not curriculum, so reach for them only when the in-memory default genuinely isn’t enough.
There’s also a variant worth naming precisely so you don’t reach for it by mistake. 'use cache: private' is the one variant that is allowed to read request APIs like cookies(), and in exchange it caches per user, in the browser’s memory only. It’s never stored on the server and never prerendered. It exists for genuinely personalized content you can’t refactor into argument-passing, and it’s still flagged experimental in Next.js 16, so it isn’t something to lean on in production yet. The default discipline in this course is the opposite: pass request data in as arguments, and let personalized-but-fresh content stream as a dynamic hole. Treat 'use cache: private' as the rarely needed escape hatch, not a tool you reach for often.
The takeaway under all of this is that caching is storage, and storage has a budget, so the move is to cache the smallest useful slice. Don’t cache the whole page reflexively, only the parts whose freshness you can afford to trade away.
Composing a cached data layer
Section titled “Composing a cached data layer”Now for the part that matters most in practice: what to actually do with all of this. The pattern experienced teams settle on is simple, and worth adopting wholesale.
Every read becomes a small 'use cache' function in your data layer. 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 of how it used to work. Before version 16, a bare fetch() was cached by default, and you opted out with { cache: 'no-store' } when you wanted fresh data. Under Cache Components that implicit behavior is gone, in line with the explicit-by-default story from the first lesson of this chapter. Now the way you cache a read is to wrap it in a 'use cache' function, so the durable rule is simply: wrap to cache. Nothing is cached or kept fresh behind your back.
The two tabs below show a refactor you’ll do constantly: a component doing an inline fetch that re-runs every render, turned into an imported cached fetcher.
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.
export async function getPost(slug: string) { 'use cache'; // cacheLife + cacheTag → next lesson const res = await fetch(`https://cms.example.com/posts/${slug}`); return res.json();}
// app/blog/[slug]/page.tsxexport async function PostPage({ slug }: { slug: string }) { const post = await getPost(slug);
return <Article post={post} />;}Move the read into a 'use cache' fetcher and import it. Now every caller passing the same slug shares one stored result.
Notice the single commented line inside the fetcher: cacheLife + cacheTag → next lesson. That’s where these fetchers gain their last two pieces. Every real cached read in your codebase will carry a lifetime and a tag, and you’ll add both in the next lesson. For now the fetcher is complete enough to work.
To close this section, review a real pull request. The next exercise is a PR that adds caching to an invoices fetcher, and it contains the two mistakes this lesson spends the most time preventing. 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.
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), });}cookies() is a request-time API, and reading it inside a 'use cache' scope is a build error — await-ing it doesn’t help. A cached entry is shared across every request and user, so it cannot depend on one request’s cookies. Read the org outside the cached function and pass it in, so it joins the key as a plain argument:
const orgId = (await cookies()).get('org')?.value;const rows = await listOrgInvoices(orgId);
// lib/invoices.tsexport async function listOrgInvoices(orgId: string) { 'use cache'; // ...}The db client is a class/SDK instance, and arguments to a cached function must be serializable so they can join the key — a class instance can’t, so this also fails the build. Don’t thread the client through the signature; import it inside the function, and pass only the plain orgId it actually needs:
import { db } from '@/lib/db';
export async function listOrgInvoices(orgId: string) { 'use cache'; return db.query.invoices.findMany({ where: eq(invoices.orgId, orgId), });}Both defects are the same lesson from two angles: only serializable, non-request data crosses into a cached scope. cookies() is request-time data (it belongs outside, passed in), and the db client is a non-serializable instance (it belongs inside, imported). The fixed fetcher takes one string argument — exactly the shape the cache key wants.
What 'use cache' is not
Section titled “What 'use cache' is not”The fastest way to use this directive well is to be clear about what it isn’t. Here are five boundaries, each of which prevents a common mistake or sets up a later lesson.
It’s not request-scoped. 'use cache' persists across requests and users; that’s its whole purpose. There’s a different tool for deduplicating work within a single request, the kind you need when the work has to read request data (think getCurrentUser(), which must read the session every request but shouldn’t run five times in one render). That tool is React’s cache(), two lessons from now. Keep the two straight: 'use cache' is cross-request storage, and cache() is per-request memoization. Confusing them is the most common mistake in this area.
It’s not a hint. It’s a contract the framework enforces at build. Break a rule, by capturing something non-serializable or reading a request API inside it, and you get a build error, not a silently slower path. This is the same build-error enforcement from the first two lessons of this chapter: the compiler tells you, up front.
It’s not free. Every entry costs storage and can be evicted. The goal isn’t to cache the whole world, only the smallest useful slice.
It’s not for client code. 'use cache' is server-only. It has no meaning inside a 'use client' module, because that code 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', named earlier, are not the default and not what this lesson taught. Plain 'use cache' is server-side, cross-request, and shared.
Run the following round to check the boundaries you just drew.
Each claim tests one of the five boundaries just drawn. Mark each statement True or False.
'use cache' deduplicates two calls within the same request but forgets them across requests.
'use cache' persists across requests and users; that’s its whole purpose. The within-a-request dedup tool is React’s cache(), two lessons from now.A cached function can read cookies() as long as it awaits it.
A cached component is allowed to return JSX.
Editing a cached function’s body invalidates its existing cache entries.
A URL instance is a valid argument to a cached function.
URL is a class instance, so it doesn’t serialize — it fails the build. Pass the string and rebuild the URL inside the function.Reveal card-by-card review
What’s next
Section titled “What’s next”You now have the full anatomy: the three placements, the cache key and what composes it, the serialization contract and its asymmetry, pass-through for the values that can’t serialize, and where the entries actually live. The next lesson adds the two pieces every real cached function carries: how long it lives (cacheLife) and the name the system uses to invalidate it (cacheTag). After that comes per-request cache(), then invalidating a cache after a mutation.