Type something to search...
The "use cache" Directive in Next.js: A Guide to the New Caching Model

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 fetch or database call runs at request time unless it's inside a "use cache" scope.
  • Route segment configs go away. export const dynamic, revalidate, and fetchCache cause errors; "use cache" and cacheLife replace 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:

  1. The build ID (or deploymentId, if configured), so a new deployment starts with a fresh cache.
  2. A function ID, a hash of where the function lives in your code.
  3. The serialized arguments, or props for a component.
  4. 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, URL instances.

"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:

Profilestalerevalidateexpire
default5 min15 minnever
seconds30 s1 s1 min
minutes5 min1 min1 hour
hours5 min1 hour1 day
days5 min1 day1 week
weeks5 min1 week30 days
max5 min30 days1 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.cache doesn't cross the boundary. Values stored with React.cache outside 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 serverIn memory (or a configured handler)Remote cache handlerNo
Shared between usersYesYesNo
Can read cookies/headersNo, pass as argumentsNo, pass as argumentsYes
Extra costNoneStorage and lookup latencyNone

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:

BeforeAfter
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 Cache prefix, so you can tell a cache hit from a fresh run.
  • Verbose logs. Run with NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev (or with npm 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.

Tags :
Share :

Related Posts

A Deep Dive into next.config Options Every Developer Should Know

A Deep Dive into next.config Options Every Developer Should Know

next.config.ts is the one file every Next.js project has and almost nobody reads end to end. It starts as an empty object, then slowly collects a r

Continue Reading
Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Adding JSON-LD Structured Data to Next.js Pages for Rich Search Results

Search engines are good at reading pages, but they still guess. Is "4.7" a rating or a version number? Is that date when the article was published or

Continue Reading
Adding Page Transitions and Animations to Next.js with Framer Motion

Adding Page Transitions and Animations to Next.js with Framer Motion

Animation is one of the easiest ways to make an app feel polished, and one of the easiest ways to make it feel slow. A subtle fade when a page loads,

Continue Reading