Type something to search...
Next.js use cache

Next.js use cache

If you've read an introductory piece on caching in the App Router, you've probably seen 'use cache' dropped at the top of a function with a one-line explanation: "add this and Next.js caches the result." That's true, but it's also the kind of statement that gets developers into trouble the first time they try anything beyond the simplest example — nesting a cached function inside another, passing a class instance as an argument, or trying to read a cookie from inside a cached scope.

use cache isn't a decorator that magically memoizes a function the way you might expect from a library like lodash.memoize. It's a directive that changes how a function or component executes inside the React Server Components model, with its own rules about what counts as a valid cache key, what values can safely cross the boundary, and what happens when you get those rules wrong. This article is the full reference: every placement the directive supports, exactly how the cache key gets built, the serialization rules that trip people up, and the failure modes you'll actually hit in practice.

What use cache Actually Marks

At its simplest, use cache marks a route, a React component, or a function as cacheable. You can place it in three positions, and each one has different scope:

File-level — placed at the very top of a file, before any imports. Every exported function in that file becomes cached, and all of them must be async.

Function-level — placed as the first line inside a specific async function. Only that function's return value is cached.

Component-level — placed as the first line inside an async component. The rendered output is cached, keyed by the component's serialized props.

// Function level
export async function getData() {
  "use cache";
  const res = await fetch("https://api.example.com/data");
  const data = await res.json();
  return data;
}

// Component level
export async function MyComponent() {
  "use cache";
  return <></>;
}

That "must be async" requirement isn't a stylistic preference — it's structural. use cache works by intercepting the function's execution and substituting a cached result when one exists, which only makes sense for something that returns a Promise in the first place. If you try to slap 'use cache' onto a synchronous function, Next.js will reject it.

Turning It On

use cache is a Cache Components feature, which means it isn't available by default — you enable it explicitly:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

This is worth calling out because a lot of people assume use cache is a standalone opt-in and are confused when it silently does nothing (or throws a build error) without cacheComponents set. The directive is the syntax; cacheComponents is the switch that makes Next.js actually respect it.

How the Cache Key Is Built

This is the part most explanations skip, and it's the part that actually determines whether your caching strategy works or quietly serves stale data to the wrong users.

A cache entry's key is a serialized combination of:

  1. Build ID — unique per deployment. A new build invalidates every cache entry from the previous one. If you've set a deploymentId, that value overrides the build ID for this purpose — useful if you're doing rolling deploys across multiple instances that need to agree on a shared identity.
  2. Function ID — a hash derived from where the function lives in your codebase and its signature.
  3. Serializable arguments — the actual props (for a component) or arguments (for a function).
  4. HMR refresh hash — development only, so hot reloading doesn't serve you a stale entry from before your edit.

The part that catches people off guard is how closures factor in. Any variable a cached function pulls from an outer scope gets automatically captured and folded into the cache key, exactly as if it had been passed as an explicit argument:

async function Component({ userId }: { userId: string }) {
  const getData = async (filter: string) => {
    "use cache";
    // Cache key includes both userId (from closure) and filter (argument)
    const res = await fetch(
      `https://api.example.com/users/${userId}/data?filter=${filter}`,
    );
    return res.json();
  };

  return getData("active");
}

Here, userId never appears in getData's parameter list, but because it's captured from the enclosing scope, it's just as much a part of the cache key as filter. Two different users calling this with the same filter value get two completely separate cache entries. This is exactly the behavior you want for per-user data, but it's also a common source of "why isn't my cache being reused" confusion when a developer expects two calls to collide and they don't, because some unrelated closed-over variable differs between them.

One subtlety worth internalizing: if a cached function reads root parameters, only the specific ones it actually touches become part of its cache key — not the full set available to the route.

What Gets Cached, and How Nested Functions Interact

A cached function produces the same output for the same inputs. The first call for a given input set executes the function body and stores the result; every subsequent call with matching inputs reuses that stored output, both within a single render and across separate requests, for as long as the entry survives.

Where this gets genuinely useful — and where the mental model needs to shift a bit — is when cached functions call other cached functions:

// lib/orders.ts
import { cacheLife } from "next/cache";

export async function getOrderSummary(accountId: string) {
  "use cache";
  cacheLife("hours");

  const orders = await getOrders(accountId);
  const totals = await getOrderTotals(accountId);

  return { orders, totals };
}

export async function getOrders(accountId: string) {
  "use cache";
  cacheLife("hours");

  return db.orders.findMany({ where: { accountId } });
}

export async function getOrderTotals(accountId: string) {
  return db.orders.aggregate({ where: { accountId }, _sum: { amount: true } });
}

getOrderSummary and getOrders each maintain their own independent cache entries, keyed by accountId. If something elsewhere in your app already called getOrders(accountId) and filled that entry, then getOrderSummary calling it again doesn't re-run the database query — it reuses the already-cached result. Meanwhile getOrderTotals is deliberately left uncached, both so other code paths can read a fresh total directly, and so you can see that caching composition doesn't require every layer in the call chain to opt in uniformly.

This composability is the actual point of use cache as a primitive rather than a route-level toggle: you decide, function by function, what layer of your data-fetching logic is worth memoizing, and the caches nest and compose naturally rather than requiring you to cache everything-or-nothing at the page level.

Serialization: The Rule That Actually Bites

Arguments to a cached function and the value it returns both have to be serializable — but not by the same rules. Arguments follow the more restrictive React Server Components serialization rules; return values follow the more permissive Client Component serialization rules. Concretely, that asymmetry means a cached function can return JSX elements but cannot accept them as an argument (except through a specific pass-through pattern, covered below).

What's supported (both directions): primitives (string, number, boolean, null, undefined), plain objects, arrays, Date, Map, Set, typed arrays, ArrayBuffer.

What's supported only in return values: JSX elements.

What's unsupported entirely: class instances, plain functions (except as pass-through), Symbol, WeakMap, WeakSet, URL instances.

// Valid — primitives and plain objects
async function UserCard({
  id,
  config,
}: {
  id: string;
  config: { theme: string };
}) {
  "use cache";
  return <div>{id}</div>;
}

// Invalid — class instance
async function UserProfile({ user }: { user: UserClass }) {
  "use cache";
  // Error: Cannot serialize class instance
  return <div>{user.name}</div>;
}

If you've ever wrapped a database row in a class (an ORM model instance, for example) and passed it straight into a cached component, this is exactly the wall you'll hit. The fix is almost always to serialize the object into a plain shape — JSON.parse(JSON.stringify(row)), or better, an explicit mapping function — before it crosses into the cached scope.

The Pass-Through Escape Hatch

There's a deliberate exception: you can accept a non-serializable value, as long as you never introspect it — meaning you don't read its properties or call it. You just forward it untouched. This is what makes children composition and Server Action props work with use cache:

async function CachedWrapper({ children }: { children: ReactNode }) {
  "use cache";
  // Don't read or modify children — just pass it through
  return (
    <div className="wrapper">
      <header>Cached Header</header>
      {children}
    </div>
  );
}

The same rule lets you pass a Server Action through a cached component without invoking it inside the cached function body:

async function CachedForm({ action }: { action: () => Promise<void> }) {
  "use cache";
  // Don't call action here — just pass it through
  return <form action={action}>{/* ... */}</form>;
}

This "don't look at it, just hand it forward" rule is genuinely counterintuitive the first time you encounter it, because most caching systems treat function arguments as an all-or-nothing serialization boundary. use cache treats "reference passed through" and "value inspected" as fundamentally different operations, which is what makes a cached layout able to render dynamic children without those children's non-serializable data poisoning the layout's cache key.

Constraints You Can't Work Around

Cached functions execute in a deliberately isolated environment, and a few restrictions exist specifically to keep that isolation meaningful rather than accidental.

No Request-Time APIs

You cannot call cookies(), headers(), or read searchParams inside a cached function or component — and this restriction propagates down the call stack. If a cached function calls a plain helper function that itself reads one of these APIs, it fails the exact same way. On a route that's dynamically rendered, this mistake can slip past next build and only surface once you actually run next start, which makes it a particularly annoying class of bug to catch in CI if your build step doesn't exercise every code path.

The fix is always the same: read the runtime value outside the cached scope, and pass it in as a plain argument.

Runtime Caching Behaves Differently Depending on Where You Deploy

use cache is designed first and foremost to let uncached, request-specific data sit inside an otherwise static shell — but it also caches data at runtime using an in-memory LRU store, and how well that persists depends entirely on your hosting environment:

EnvironmentRuntime Caching Behavior
ServerlessEntries typically don't survive across requests, since each invocation can land on a different instance. Build-time caching is unaffected.
Self-hostedEntries persist across requests in the same process. Size is controlled via cacheMaxMemorySize.

This is a real gotcha for teams that develop locally (self-hosted, effectively) and then deploy to a serverless platform: caching behavior that looked rock-solid in local testing can behave completely differently once every request potentially hits a cold instance with an empty cache. If your workload genuinely needs cache entries that survive across ephemeral serverless instances, the in-memory default won't get you there — you need use cache: remote, which delegates to an external store like Redis at the cost of a network round trip and, usually, a platform bill.

It's also worth knowing that neither the in-memory nor the remote cache survives a new deploy, because the build ID (or deploymentId) is baked into every cache key. If you need data that persists across deploys, reach for unstable_cache or the extended fetch cache instead — use cache isn't built for that job.

Draft Mode Bypasses the Cache Entirely

When Draft Mode is active, every cached function and component re-executes on every request and nothing gets written to the cache. You don't need to special-case your caching logic for preview content — Next.js handles the bypass for you. You can read isEnabled from draftMode() inside a cached scope (it's explicitly carved out as an exception), but you still can't call cookies() or headers() there, even while draft mode is on.

React.cache Doesn't Cross the Boundary

If you're used to React.cache as a request-scoped memoization tool, know that it operates in its own isolated scope inside a use cache boundary. A value set via React.cache outside a cached function is invisible once you're inside one:

import { cache } from "react";

const store = cache(() => ({ current: null as string | null }));

function Parent() {
  const shared = store();
  shared.current = "value from parent";
  return <Child />;
}

async function Child() {
  "use cache";
  const shared = store();
  // shared.current is null here, not 'value from parent'
  return <div>{shared.current}</div>;
}

This isolation is deliberate — it keeps a cached function's behavior self-contained and predictable, rather than depending on ambient state set somewhere else in the render tree. If you need to get a value into a cached scope, pass it as a function argument. There's no back door.

Revalidation: Time-Based, Tag-Based, or Both

Cached functions expire according to the revalidate and expire windows in their cacheLife profile, or you can invalidate them on demand by tag. These aren't either/or — most real applications pair them.

import { cacheLife } from "next/cache";

async function getData() {
  "use cache";
  cacheLife("hours");
  const res = await fetch("https://api.example.com/data");
  return res.json();
}

If you skip cacheLife entirely, the default profile applies implicitly: 5 minutes stale on the client, 15 minutes revalidate on the server, and no time-based expiry at all. That's a reasonable fallback, but it means the cache behavior for that function is invisible at the call site — you have to go look up what "default" means rather than seeing it declared. I'd treat an explicit cacheLife call as close to mandatory in anything beyond a quick prototype, purely for that readability reason.

For invalidating on a mutation rather than waiting for time to pass, tag the cached function and invalidate the tag from a Server Action:

// lib/data.ts
import { cacheTag } from "next/cache";

async function getProducts() {
  "use cache";
  cacheTag("products");
  const res = await fetch("https://api.example.com/products");
  return res.json();
}
// app/actions.ts
"use server";
import { updateTag } from "next/cache";

export async function updateProduct() {
  await db.products.update(/* ... */);
  updateTag("products"); // Invalidates every 'products' cache entry
}

A practical pattern that falls out of this: give content that only changes when someone explicitly edits it — a blog post, a product listing — a long cacheLife like 'max' paired with a tag, and invalidate on save. Give content that naturally drifts throughout the day — a "recent activity" feed — a shorter profile like 'hours' and skip the tag machinery entirely. Trying to force everything through tag-based invalidation when a short time window would do is usually more plumbing than the problem warrants.

Practical Patterns Worth Knowing

Whole-file caching. Put 'use cache' at the very top of a file, and every export in it must be async and is treated as cached:

// app/lib/reports.ts
"use cache";

export async function getMonthlyTotals(accountId: string) {
  return db.orders.aggregate({ where: { accountId }, _sum: { amount: true } });
}

export async function getTopProducts() {
  return db.products.findMany({ orderBy: { sales: "desc" }, take: 10 });
}

Framework hooks like generateMetadata and generateStaticParams are swept up by this too if they live in the same file — which means they need to become async if they aren't already.

One thing worth knowing: functions exported from a file-level cached module can be imported directly into a Client Component and called there, and they'll still execute on the server, behaving essentially like a Server Function. It works, but the docs are explicit that calling cached functions server-side and passing the result down as props is the preferred pattern — reaching for it from the client should be the exception, not the default.

Caching a whole route segment. A page.tsx or layout.tsx is a module like any other, so the file-level directive applies the same way there. To fully prerender a route, every segment that contributes to it — the page, the layout, and any parallel route slots — needs its own 'use cache' at the top:

// app/layout.tsx
"use cache";

export default async function Layout({ children }: { children: ReactNode }) {
  return <div>{children}</div>;
}

Critically, a cached layout does not cache the children it renders — that's the interleaving behavior described earlier, where slots pass through without contaminating the parent's cache entry.

When Your Build Hangs

If a build seems to hang indefinitely rather than fail cleanly, the near-universal cause is a cached function awaiting a Promise that resolves to request-specific or uncached data, created outside a use cache boundary and then handed in — as a prop, via closure, or pulled out of some shared Map used for deduplication.

// app/page.tsx
import { cookies } from "next/headers";
import { Suspense } from "react";

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Dynamic />
    </Suspense>
  );
}

async function Dynamic() {
  const cookieStore = cookies();
  return <Cached promise={cookieStore} />; // Build hangs
}

async function Cached({ promise }: { promise: Promise<unknown> }) {
  "use cache";
  const data = await promise; // Waits for runtime data during build
  return <p>..</p>;
}

During prerendering, Cached sits there waiting on a Promise that can never resolve at build time — because it depends on a real request — and after 50 seconds it times out with a build error naming the exact problem: request-specific arguments used inside use cache. The fix is to await the dynamic value in the uncached component and pass a plain resolved value down, not the Promise itself. The same failure shows up if you stash a dynamic Promise in a shared Map for deduplication purposes and a cached function later reads from that Map — the fix there is to keep separate storage for cached and uncached code paths, or lean on fetch's own built-in deduplication instead of hand-rolling it.

Note that directly calling cookies() or headers() inside a cached scope fails immediately and explicitly, with a distinct error — it's specifically Promises resolving to runtime data that produce the silent hang instead of a clean failure.

Debugging Cache Behavior

When you need to see what's actually happening, set NEXT_PRIVATE_DEBUG_CACHE=1 before running dev or start:

NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev

This also logs ISR and other caching activity, so it's a reasonable first move any time cache behavior doesn't match your mental model, not just for use cache specifically. In development, console logs emitted from inside a cached function are also prefixed with Cache when they replay, which makes it obvious when you're looking at output from a cache hit versus a fresh execution.

Where It Doesn't Work

use cache isn't universally available across every deployment target:

Deployment OptionSupported
Node.js serverYes
Docker containerYes
Static exportNo
AdaptersPlatform-specific

Static export is the one to remember — if you're building a fully static site with output: 'export', use cache simply isn't part of that story, since there's no server present at runtime to serve cache misses.

Key Takeaways

QuestionAnswer
Where can 'use cache' go?File level (all exports), function level (one function), or component level (one component's render output)
What must the target be?async — always
What's in the cache key?Build ID, function ID, serializable arguments, closed-over variables, and (dev only) the HMR hash
Can I pass a class instance as an argument?No — serialize it to a plain object first
Can I return JSX?Yes, in return values only, not as an argument (except via pass-through)
Can I read cookies()/headers() inside?No, not even transitively through a helper function
Does it persist across deploys?No — the build ID is part of every cache key
Does Draft Mode respect the cache?No — it bypasses caching entirely while enabled

use cache rewards being explicit. Set an explicit cacheLife instead of relying on the default profile, keep runtime-only values outside the cached scope and pass them in as arguments, and treat the pass-through rule for children and Server Actions as the specific, narrow exception it is rather than a general escape hatch. Get those habits right early, and the directive does exactly what the name promises — quietly, predictably, and without the mystery debugging sessions that come from treating it like a simple memoization decorator.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper