← blog
NextjsSeptember 1, 2026 · 14 min read

Next.js Cache Components Explained (with Cheat Sheet)

How Next.js Cache Components decide what's static, what's cached, and what streams — the use cache directive, cacheLife, and Suspense explained.

Parsa Jiravand · Frontend engineer · building bestpractic
Next.js Cache Components Explained (with Cheat Sheet)

You add one line to a layout — const theme = (await cookies()).get('theme')?.value — to greet returning visitors by their saved preference. Nothing else changes. Deploy, and your blog's server load quadruples: every page that used to serve instantly from a CDN edge now renders fresh, on your origin server, for every single visitor, including the 95% of the page that is identical for everyone.

Nothing you wrote was wrong, exactly. It's how the App Router's previous rendering model worked: one dynamic API call anywhere in a route's tree marked the entire route dynamic. Next.js's new Cache Components model exists specifically to fix this, and understanding how it decides what's static, what's cached, and what streams is the single most valuable thing you can know about the framework right now.

By the end of this article you'll be able to:

  • Explain why a single cookies() or headers() call used to make a whole Next.js route dynamic, and how Cache Components changes that
  • Use the use cache directive at the function, component, and file level, and know which one to reach for
  • Set explicit cache lifetimes with cacheLife and invalidate on demand with cacheTag + updateTag
  • Read a route and predict which parts become the static shell, which get cached, and which stream in behind a <Suspense> boundary
  • Avoid the constraints that trip people up first: reading runtime APIs inside a cached scope, and passing uncached promises into one

You've built at least a small App Router project — a page.tsx, a layout.tsx, maybe a fetch call inside a Server Component. You don't need any prior experience with caching APIs; we build the model from nothing.

This article is written against Next.js 16.3 (verified against the framework's own documentation and npm's latest dist-tag in August 2026). Cache Components shipped as an opt-in flag in Next.js 16.0 and is the model this article teaches; where the still-supported previous model (implicit fetch caching, route segment configs like export const dynamic) differs, it's called out explicitly rather than left implied.

Here's a blog layout that reads a saved theme preference so it can render the right class on <body>:

TSX
1
2
3
4
5
6
7
8
9
10
11
// app/layout.tsx — pre-Cache-Components App Router import { cookies } from 'next/headers'; export default async function RootLayout({ children }: { children: React.ReactNode }) { const theme = (await cookies()).get('theme')?.value ?? 'light'; return ( <html lang="en"> <body className={theme}>{children}</body> </html> ); }

cookies() is a request-time API — it can only produce a value once an actual request exists, so there's no way to know it at build time. In the App Router's previous rendering model, that fact wasn't scoped to the component that called it: reading a dynamic API anywhere in a route's component tree opted the entire route out of static rendering. The header, the article body, the footer, the "10 related posts" list that's the same for every visitor — all of it now re-renders on the server, on every request, because one <body> class needed to know something about the current user.

You can work around this in the previous model (extract the theme read into a small Client Component that reads document.cookie after hydration, for instance), but the workaround is the tell: the framework's default behavior didn't distinguish "this one value needs live data" from "this route needs live data." Cache Components draws that line at the component, not the route.

The mental model: in the previous model, a route got one verdict — static or dynamic — decided by the most demanding thing anywhere in its tree. With Cache Components enabled, that verdict moves down to individual functions and components. Each one is either cached (with an explicit lifetime), streamed behind a <Suspense> boundary, or — if it does neither and touches something request-specific — flagged by the framework as needing one of those two treatments before the build will pass.

Next.js still produces one artifact per route: a static shell, prerendered at build time, containing every static and cached piece plus fallback UI for anything still streaming. That shell is what a CDN can serve instantly on a direct visit. The pieces behind <Suspense> fill in afterward, at request time, without dragging the rest of the page down with them.

Cache Components is an opt-in flag as of Next.js 16.0 — a fresh create-next-app project doesn't enable it by default yet:

TypeScript
1
2
3
4
5
6
7
8
// next.config.ts import type { NextConfig } from 'next'; const nextConfig: NextConfig = { cacheComponents: true, }; export default nextConfig;

Key concept: this one flag replaces three separate experimental flags from Next.js 15 (dynamicIO, useCache, and ppr) with one unified setting, and it requires the Node.js runtime — routes still exporting the deprecated runtime = 'edge' need to migrate first.

The use cache directive marks an async function's or component's return value as cacheable. Start with a plain data-fetching function:

TypeScript
1
2
3
4
5
6
7
8
9
// app/lib/posts.ts import { cacheLife } from 'next/cache'; export async function getRecentPosts() { 'use cache'; cacheLife('hours'); const res = await fetch('https://api.example.com/posts'); return res.json(); }

The first call with a given set of inputs runs the function and stores the result; every later call with the same inputs — including different requests, from different visitors — reuses it, until the lifetime you set with cacheLife expires. Arguments and any variables captured from an outer scope become part of the cache key automatically, so getRecentPosts(category) called with two different categories gets two separate cache entries.

Key concept: use cache caches a result, keyed by its inputs — not a route, not a URL. That's what lets a component ten levels deep cache independently from everything around it.

The same directive works at the component level, caching everything the component renders:

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// app/blog/recent-posts.tsx import { cacheLife, cacheTag } from 'next/cache'; export async function RecentPosts() { 'use cache'; cacheLife('hours'); cacheTag('posts'); const res = await fetch('https://api.example.com/posts'); const posts: { id: string; title: string }[] = await res.json(); return ( <ul> {posts.map((p) => ( <li key={p.id}>{p.title}</li> ))} </ul> ); }

cacheLife('hours') isn't a made-up duration — it's one of six built-in profiles, each balancing three numbers: how long the client trusts a cached copy without checking (stale), how often the server regenerates it in the background (revalidate), and when it's dropped entirely if nobody's asked for it (expire). Omit cacheLife and the default profile applies implicitly — which works, but leaves the lifetime invisible at the call site. Naming it explicitly is the recommended habit.

If this result is part of what could go into the route's prerendered static shell (its stale window is long enough), it's filled in at build time and served straight from a CDN on a direct visit — no server round trip at all.

Back to the theme example. The fix isn't to avoid cookies() — it's to contain it:

TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// app/layout.tsx — with Cache Components import { cookies } from 'next/headers'; import { Suspense } from 'react'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Suspense fallback={null}> <ThemeBody>{children}</ThemeBody> </Suspense> </body> </html> ); } async function ThemeBody({ children }: { children: React.ReactNode }) { const theme = (await cookies()).get('theme')?.value ?? 'light'; return <div className={theme}>{children}</div>; }

RootLayout itself no longer awaits cookies(), so it isn't request-dependent, and it completes during prerendering. ThemeBody is the only thing that streams in at request time — the header, the article body, and everything else in children that's cached or static ships in the initial shell exactly as before. One component's need for live data no longer taxes the whole page.

Key concept: <Suspense> doesn't make a component dynamic — it gives a component that's already dynamic (because it reads a runtime API, or fetches without caching) somewhere to put its fallback so the rest of the shell doesn't have to wait for it. A component that only does synchronous work completes during prerendering regardless of whether it's wrapped.

Time-based expiry (cacheLife) and on-demand invalidation (cacheTag) aren't alternatives — they're usually paired. RecentPosts above tagged its cache entry 'posts'. When a new post is published, invalidate every entry with that tag, from anywhere the mutation happens:

TypeScript
1
2
3
4
5
6
7
8
9
// app/actions.ts 'use server'; import { updateTag } from 'next/cache'; export async function publishPost(formData: FormData) { await db.posts.create({ /* ... */ }); updateTag('posts'); // every 'posts'-tagged cache entry is now stale }

This is the same pattern as the older revalidateTag, but updateTag is aware of Cache Components' server and client caches together, so tagging and invalidating stays a single mental step regardless of which layer actually stored the result.

Runs right in your browser — poke at it and watch the concept react live.

  • A cached scope can't read runtime APIs at all. Calling cookies(), headers(), or reading searchParams directly inside a use cache function — or inside anything it calls — throws. Read the value in an uncached component first, then pass it as an argument to the cached function; the argument becomes part of the cache key.
  • Passing an uncached promise into a cached function hangs the build. If a use cache function awaits a promise that resolves to request-specific or otherwise-uncached data (received as a prop, from a closure, or from shared storage like a Map), the build waits for data that can never resolve during prerendering and times out after 50 seconds. Await the value outside the cached scope and pass the resolved value in.
  • Math.random(), Date.now(), and crypto.randomUUID() need an explicit choice. They produce a different value every call, so Cache Components requires you to say what you mean: call connection() before them and wrap in <Suspense> to get a genuinely unique value per request, or wrap them in use cache so every visitor sees the same value until it revalidates.
  • Serialization has real limits. Arguments and cached return values must be serializable — primitives, plain objects, arrays, Date/Map/Set, and (for return values only) JSX. Class instances, functions, and URL instances aren't allowed, except as opaque pass-through props like children.
  • Draft Mode bypasses the cache entirely. With Draft Mode enabled, every cached function re-executes on every request and nothing is written to the cache — by design, so preview content is never stale.
  • Bots and crawlers skip the shell. Because they need a complete document, Next.js detects them by user agent and renders the whole page dynamically at request time instead of serving the static shell. If any part of your shell depends on build-time-only data, make sure the same data is reachable at request time too, or a page that renders for a person can fail for a crawler.

  • Push runtime API reads as deep into the tree as they'll go. A params or cookies() read at the top of a layout blocks everything below it from being static; the same read three components down blocks only that subtree. The deeper the dynamic work sits, the more of the page prerenders.
  • Pair every use cache with an explicit cacheLife. The implicit default profile (5-minute stale, 15-minute revalidate, never expires) works, but naming the profile you actually mean documents the decision at the call site.
  • Reach for cacheTag + updateTag for anything invalidated by a mutation, and a longer cacheLife (days, weeks, or max) for content that only changes when someone edits it — the two together mean you rarely need a short polling-style lifetime.
  • Don't cache what should stream. A component that genuinely needs the current request — a cart total from a session cookie, a personalized recommendation — belongs behind <Suspense>, not squeezed into a cache with an artificially short lifetime.
  • use cache: private is the exception, not the default. It exists for cases where you can't refactor to pass runtime data as arguments; reach for it rarely, since regular use cache plus an extracted argument covers most real cases and stays easier to reason about.

No. It's an opt-in flag. Without it, your app uses the previous rendering model — fetch requests are uncached by default (a change from Next.js 14), and route segment configs like export const dynamic and revalidate still work exactly as before.

Not entirely — revalidatePath still exists for the previous model's route-level cache. Inside Cache Components, prefer cacheTag plus updateTag (or revalidateTag), which target specific cached results by tag rather than an entire route.

Related, not identical. Partial Prerendering — a static shell plus streaming holes — is the rendering behavior Cache Components implements by default. cacheComponents: true is the single flag that turns PPR on along with use cache and the removal of implicit dynamic-API-triggers-whole-route behavior; you no longer set an experimental PPR flag separately.

No. Every cache key includes the build ID (or your configured deploymentId), so a new deploy starts with an empty cache, even for the durable use cache: remote variant. That's deliberate — it guarantees a deploy never serves output built from stale code.

Nothing breaks — the default profile applies (5-minute client-side stale window, 15-minute server-side revalidate, no time-based expiry). The framework recommends setting it explicitly anyway, since the alternative is a lifetime that's easy to lose track of.

TaskCodeNotes
Enable Cache ComponentscacheComponents: true in next.config.tsRequires Node.js runtime
Cache a data function'use cache' at the top of an async functionResult keyed by arguments + captured closures
Cache a whole component'use cache' at the top of an async componentComposed children/slots pass through uncached
Cache every export in a file'use cache' at the top of the fileEvery exported function must be async
Set an explicit lifetimecacheLife('hours')One call per function invocation, inside the cached scope
Tag a cache entrycacheTag('posts')Pairs with updateTag/revalidateTag
Invalidate on demandupdateTag('posts') inside a Server ActionInvalidates every entry with that tag
Stream request-specific dataWrap in <Suspense fallback={...}>, read cookies()/headers() insideFallback ships in the static shell; content streams at request time
Get a unique value per requestawait connection() then Math.random()/Date.now(), inside <Suspense>Forces request-time evaluation instead of a cached build-time value
TSX
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// The pattern in one page: static, cached, and streaming together import { Suspense } from 'react'; import { cookies } from 'next/headers'; import { cacheLife, cacheTag } from 'next/cache'; export default function BlogPage() { return ( <> <header>Static — prerendered automatically</header> <RecentPosts />{/* cached, joins the static shell */} <Suspense fallback={<p>Loading your preferences…</p>}> <UserPreferences />{/* streams in at request time */} </Suspense> </> ); } async function RecentPosts() { 'use cache'; cacheLife('hours'); cacheTag('posts'); const posts = await fetch('https://api.example.com/posts').then((r) => r.json()); return <ul>{posts.map((p: any) => <li key={p.id}>{p.title}</li>)}</ul>; } async function UserPreferences() { const theme = (await cookies()).get('theme')?.value ?? 'light'; return <aside>Theme: {theme}</aside>; }

  • The previous App Router model gave each route one verdict — static or dynamic — decided by its most demanding component. Cache Components moves that decision down to individual functions and components.
  • use cache caches a result by its inputs; pair it with an explicit cacheLife so the lifetime is visible at the call site, not implicit.
  • <Suspense> is how a genuinely request-specific piece streams in without dragging the rest of the page's caching down with it — it doesn't make a component dynamic, it gives one that already is somewhere to put its fallback.
  • Time-based (cacheLife) and on-demand (cacheTag + updateTag) revalidation are complementary, not competing — most real content wants both.
  • The line between "this needs live data" and "this route needs live data" is now drawn at the component, and that's the whole point.

The fix for the theme cookie wasn't to stop reading it — it was to stop letting one read decide the fate of everything around it. Move the read into its own component, wrap it in <Suspense>, and the header, the article, and the "related posts" list go back to shipping from the edge, instantly, while the one thing that actually needed to know who's visiting still gets to ask.

If you want the background this article assumes — what a Server Component actually is and why "it's just SSR" is the wrong mental model — Server Components Without the Hype covers that half, and this article picks up from there.

What's the last route you had to manually pull out of "fully dynamic" — and did you know at the time which single line caused it?

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.


🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.

Thanks for reading! Let's stay connected:

Keep reading

One post a day, in your inbox

Each one with a runnable playground and a quiz. No pitch, no digest, unsubscribe in one click.

0 comments

Sign in to join the discussion, like comments, and save articles for later.