Next.js 16 ships Cache Components as a first-class caching model: a single 'use cache' directive, paired with cacheLife, that you can drop on a route, a component, or a plain data-fetching function. The pitch is appealing: pick your own granularity, mix static and dynamic content on the same page, and let Partial Prerendering figure out the rest.
That pitch also raises a question the docs don't spend much time on: does it actually matter where you put 'use cache'? If you wrap an entire component versus just the fetch call inside it, what changes, for the reader and for the server doing the work?
To find out, I built a small dashboard with three crypto price cards (Bitcoin, Ethereum, Solana), all pulling the same kind of data from the same public API, each caching it a different way:
- Bitcoin caches the fetch and the render together, at the component level.
- Ethereum caches only the fetch; the chart underneath is recomputed on every request.
- Solana caches nothing at all.
All three run under the exact same cacheLife profile. The only variable is what each card chooses to cache, not how long it's cached for. That constraint turned out to matter: it's what forced some genuinely non-obvious behavior into the open, the kind you won't hit until you've deployed something and watched it run for a few minutes under real traffic.
This is a walkthrough of what I found, with the actual numbers, not just the theory.
Quick Reference
All three cards share the exact same cacheLife("demo") profile (stale: 60, revalidate: 10, expire: 300). The only variable is what each one chooses to cache.
| Card | What's cached | First-load time (warm cache) | Re-runs on every request? |
|---|---|---|---|
| Bitcoin | Fetch + chart computation + JSX, via 'use cache' on the component |
~40ms | No — only after the 10s revalidate window passes and a request triggers the refresh |
| Ethereum | Fetch only, via 'use cache' on the data function |
~445ms | Chart: yes, every request. Fetch: no |
| Solana | Nothing | ~1084ms | Yes — fetch and chart, every request |
cacheLife's prerendering rules force a cache scope into a dynamic hole (excluded from the static shell) once any of these thresholds is crossed:
cacheLife field |
This demo's value | Forces dynamic below/at |
|---|---|---|
stale |
60s | < 30s |
revalidate |
10s | 0 |
expire |
300s (5m) | < 5m |
The demo
The app itself is deliberately small: one route, three cards, no database. Each card fetches live price data for its coin from Binance's public REST API (no key required), renders a price, a 24h change, and a small chart. Under the hood, each card also prints two things most demos hide: an execution counter and a timestamp, both of which only update when the card's data-fetching function actually runs, not on every page load. That's the whole trick this article leans on: instead of guessing whether something was cached, you can watch the counter and just... check.
Stack-wise it's nothing exotic: Next.js 16.3.2, App Router, TypeScript, Tailwind. The full source is on GitHub if you want to run it yourself; every number quoted below came from that exact code, built and served with next build && next start. That last part matters more than it sounds: 'use cache' behaves differently under next dev, so next dev is not where you want to be verifying any of this.
Component-level: cache everything, render included
The 'use cache' directive can sit at the top of a function or a component. Put it on a component, and the entire thing (data fetching, computation, the JSX it returns) gets cached as one unit. That's the Bitcoin card:
import { cacheLife } from "next/cache";
import { computeExpensiveChart } from "@/lib/chart";
import { COINS, fetchBinancePrice, recordExecution } from "@/lib/prices";
import { CryptoCard } from "./crypto-card/CryptoCard";
export async function BitcoinCard() {
"use cache";
cacheLife("demo");
const { price, change24h } = await fetchBinancePrice("bitcoin");
const { executionCount, fetchedAt } = recordExecution("bitcoin");
const { sparklinePath } = await computeExpensiveChart(price, change24h);
return (
<CryptoCard
price={price}
change24h={change24h}
sparklinePath={sparklinePath}
executionCount={executionCount}
fetchedAt={fetchedAt}
// ...tier, name, symbol
/>
);
}
Three things happen inside that cached scope: fetching the price from Binance, recording that a real execution happened, and computing the chart's SVG path. computeExpensiveChart stands in for something you'd actually see in production: a heavier chart library, a technical-indicator calculation, a template render. So I gave it a real, measurable cost (a few hundred milliseconds) instead of pretending it's free.
The cacheLife call is what gives this cache entry a lifetime. "demo" is a custom profile defined in next.config.ts rather than one of the built-ins ("minutes", "hours", and so on); more on why, and on the exact numbers, in a couple of sections. For now, the point is simpler: on a cache hit, none of that work happens. Not the fetch, not the chart computation. The component just returns whatever was computed and stored the last time this cache entry was filled.
What actually gets stored isn't HTML, and it isn't a plain object of { price, change24h, ... } either. It's a serialized RSC payload: the same wire format React Server Components normally stream to the client, just persisted instead of sent once and discarded. That's also why 'use cache' is allowed to return JSX at all: return values follow client-serialization rules, which are more permissive than the rules for a cached function's arguments.
From that one stored payload, Next.js can produce whatever a given request needs: HTML for the prerendered static shell, the same payload replayed as-is for another server hit, or shipped straight to the browser so a client-side navigation can reuse it without a round trip.
Fetch-level: cache the data, not the render
'use cache' doesn't have to sit on a component. Put it on a plain async function instead, and only that function's return value gets cached; nothing about the component that calls it changes:
// lib/prices.ts
export async function getEthereumSnapshot(): Promise<PriceSnapshot> {
"use cache";
cacheLife("demo");
const { price, change24h } = await fetchBinancePrice("ethereum");
const { executionCount, fetchedAt } = recordExecution("ethereum");
return { price, change24h, executionCount, fetchedAt };
}
// EthereumCard.tsx
export async function EthereumCard() {
const { price, change24h, executionCount, fetchedAt } =
await getEthereumSnapshot();
const { sparklinePath } = await computeExpensiveChart(price, change24h);
return (
<CryptoCard
price={price}
change24h={change24h}
sparklinePath={sparklinePath}
executionCount={executionCount}
fetchedAt={fetchedAt}
// ...tier, name, symbol
/>
);
}
EthereumCard itself has no 'use cache' at all. It calls a cached function to get pricing data, then unconditionally recomputes the chart from that data on every single request: same computeExpensiveChart, same artificial cost, as Bitcoin's card. Same cacheLife("demo"), too: the fetch is cached on the exact same schedule as Bitcoin's whole component. The only difference between these two cards is what's inside the cached scope versus what's outside it.
Here's the part that isn't obvious going in: that difference alone is not what makes Ethereum's chart recompute every time. If EthereumCard had no reason to be treated as dynamic, Next would happily bake the entire thing (cached fetch, chart computation and all) into the same kind of static shell Bitcoin gets, on the theory that nothing about it depends on the incoming request. Cache placement controls what gets cached; it doesn't, by itself, control how often a component gets re-evaluated. Those are two separate questions, and it's easy to assume the first answers the second.
What actually forces EthereumCard to run on every request is computeExpensiveChart itself: an await with no 'use cache' around it, sitting in a component that also has no 'use cache' around it. Next can't treat that as predictable output it's safe to freeze at build time, so the component becomes a genuine dynamic hole: resolved fresh, on the server, for every single request. The cached fetch inside it is just along for the ride: fast when it's warm, but that speed has no bearing on whether the component around it reruns.
Zero cache: the baseline
Solana's card doesn't try to be clever:
export async function SolanaCard() {
const { price, change24h, executionCount, fetchedAt } =
await getSolanaSnapshot();
const { sparklinePath } = await computeExpensiveChart(price, change24h);
return (
<CryptoCard
price={price}
change24h={change24h}
sparklinePath={sparklinePath}
executionCount={executionCount}
fetchedAt={fetchedAt}
live
/>
);
}
No 'use cache' anywhere in the chain: not on the component, not on getSolanaSnapshot. The fetch alone is enough to make this a dynamic hole, for the same reason as Ethereum's chart: an uncached async operation with nothing caching it. Every request pays for both the network round trip to Binance and the chart computation, in full, every time. That's the baseline the other two cards get compared against.
How to measure this for real
Explaining what should be cached is one thing. Watching it happen is more convincing, so each card carries two independent signals, and neither is a number the code invents.
The first is server-side: an execution counter plus a timestamp, both updated only inside the function that does the real work: the fetch for Ethereum and Solana, the whole component for Bitcoin. A cache hit means that code doesn't run, which means the counter doesn't move. No inference required, just read the number.
The second is client-side, and answers a different question: not "was this cached," but "how long did the reader actually wait for it." Each card mounts a small client component the instant its content reaches the browser:
"use client";
import { useEffect, useRef } from "react";
export function AppearedAtBadge() {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const node = ref.current;
if (node) {
node.textContent = `appeared after ${Math.round(performance.now())}ms`;
}
}, []);
return <span ref={ref}>…</span>;
}
performance.now() inside a useEffect fires the moment this specific node mounts. For a card behind its own <Suspense> boundary, that's the moment its streamed chunk actually arrived, not when the initial HTML shell did. (It writes to the DOM directly instead of calling setState, on purpose: a setState here would just trigger an extra render for a value nothing else depends on.) Since Bitcoin, Ethereum, and Solana each stream independently, this produces three different numbers on the same page load. On a warm run, measured against this exact code:
| Card | Appeared after |
|---|---|
| Bitcoin | ~40ms |
| Ethereum | ~445ms |
| Solana | ~1084ms |
That's the whole argument in one table. Bitcoin's number is just network latency: the cache hit means there's nothing else to wait for. Ethereum pays for the chart, once, and skips the fetch. Solana pays for both, every time.
Three things about stale, revalidate, and expire worth knowing before you hit them in production
Bitcoin and Ethereum share one cacheLife profile (see Quick Reference above), precisely so any difference between them comes from what's cached, not how long. Getting that shared profile right surfaced a few behaviors that aren't obvious from a first read of the docs.
A short lifetime can silently force a <Suspense> boundary you didn't ask for. cacheLife's prerendering rules exclude a cache scope from the static shell, turning it into a dynamic hole, under three conditions: revalidate: 0 or expire under 5 minutes, or stale under 30 seconds. Hit any of them on a component you expected to be static, like Bitcoin, and next build fails outright, pointing at that component with an error about uncached data outside a <Suspense> boundary.
revalidate never blocks a request, and it never fires without one. The background regeneration model is classic stale-while-revalidate: once a cache entry's age passes revalidate, the server keeps serving the existing value immediately while refreshing it in the background; nobody waits. If nothing asks, nothing refreshes, no matter how much wall-clock time passes.
A fresh build and a fresh process don't share state, even though they share a build. This one's specific to caching at the component level, and it's the kind of thing you only notice if you're watching closely. next build genuinely executes Bitcoin's cached component once, to produce the static shell, including our counter, which ticks up to 1 inside that short-lived build process. When next start boots, it's a new process with its own fresh copy of that counter, starting back at 0. So the first real execution inside the running server also lands on "1", the same number the build already showed, just with a new price behind it. Only the second live execution correctly shows "2". If you're using an in-memory counter as a "did this actually run" signal, this is the one moment it lies to you; a timestamp doesn't have the same problem, since it never resets.
None of these are bugs. They're the kind of detail that only shows up once you deploy something and watch it for a few minutes instead of trusting the mental model you built from the docs alone.
How this compares to classic ISR
If you've used Incremental Static Regeneration before (getStaticProps with a revalidate option, or export const revalidate = 60 in the App Router), the stale-while-revalidate mechanics in this article will feel familiar, because it's the same underlying model. What's different is the unit it applies to. Classic ISR revalidates a page: one lifetime, one cache entry, for everything that page renders. Getting Bitcoin-style and Ethereum-style behavior on the same route used to mean either splitting them into separate routes or reaching for something like unstable_cache to hand-roll a second cache boundary inside the page.
Cache Components collapses that gap by making the component or function the unit instead of the route. Bitcoin and Ethereum sit on the same page, share the same cacheLife, and still behave completely differently, because the boundary moved, not because the underlying revalidation logic changed. If you're already comfortable with ISR's mental model, most of what's new here is granularity, not a new set of rules to learn from scratch.
Picking a strategy
Three cards, three defensible choices, and the right one depends on what's actually expensive:
- Cache the component (Bitcoin) when the render itself costs real time (a chart library, a heavy transform, anything downstream of the fetch) and that render doesn't need to be fresher than the data feeding it. Bundling both together means a cache hit skips all of it, not just the network call.
- Cache just the fetch (Ethereum) when the data can tolerate being a little stale but what you build from it can't: per-request personalization, something reading a cookie, anything that has to reflect this request even though the numbers behind it don't change every second.
- Cache nothing (Solana) when the data genuinely has to be live, and go in knowing you're paying for the fetch and everything downstream of it, every single time, deliberately.
The instinct worth keeping is to verify, not assume. 'use cache' placement determines what's frozen; it doesn't determine when your code runs. That's still governed by whether anything nearby gives Next a reason to treat the surrounding component as dynamic. As with most caching decisions, there's no universal answer here; it depends entirely on the use case in front of you. What does generalize is the method: a counter and a timestamp that only move on real work turn that decision into something you can check, not something you have to guess at.
Conclusion
Cache Components don't change the mental model of stale-while-revalidate caching — they change its unit, from the whole route down to a single component or function. That extra granularity is worth having, but it comes with a sharp edge: placement controls what's frozen, not how often the surrounding code reruns, and the two are easy to conflate until you've measured them separately, as the Bitcoin/Ethereum/Solana comparison above does. If you're building on Next.js and want this kind of caching behavior verified against real numbers before it ships, that's the same kind of work u11d's web development team does on React/Next.js projects.
Resources
- Cache Components — Next.js docs
'use cache'directive referencecacheLifefunction reference and prerendering rules- How revalidation works
- Incremental Static Regeneration guide
- Demo source code on GitHub
- Implementing Postgres Row-Level Security in Next.js: The Drizzle Multi-Tenant Pattern — another Next.js production deep-dive on this blog
- Building a Multi-Language Shop with Next.js App Router — another Next.js App Router deep-dive on this blog
FAQ
What does Next.js's 'use cache' directive actually cache?
'use cache' stores whatever the function or component it wraps returns, serialized as an RSC (React Server Components) payload — not raw HTML and not a plain JavaScript object. On a component, that means the data fetch, any computation inside it, and the JSX it returns are all cached together as one unit; on a plain function, only that function's return value is cached.
Does it matter whether I put 'use cache' on a component or on the fetch function?
Yes. Caching a whole component in Next.js caches the fetch, any computation, and the rendered output together, so a cache hit skips all of it. Caching only the data-fetching function leaves everything else in the component — like a chart calculation — to run on every request, even though the data behind it came from cache.
Why does my Next.js component still re-run on every request even though I cached the data fetch with 'use cache'?
Because an uncached await anywhere else in that component — a chart computation, a transform, another fetch — forces Next.js to treat the whole component as a dynamic hole that gets re-evaluated per request. Caching a function only freezes that function's own output; it doesn't stop Next.js from re-running everything around it.
Why did next build fail with an error about uncached data outside a <Suspense> boundary?
Next.js's cacheLife prerendering rules exclude a cache scope from the static shell — turning it into a required dynamic hole — whenever revalidate is 0, expire is under 5 minutes, or stale is under 30 seconds. Hitting one of these thresholds on a component you expected to prerender statically makes next build fail unless that component is wrapped in <Suspense>.
Does cacheLife's revalidate option refresh the cache automatically in the background?
No. revalidate only marks a Next.js cache entry as stale once its age passes that threshold — nothing refreshes it until the next real request arrives, at which point Next.js serves the existing stale value immediately and refreshes it in the background. If no request comes in, the cache never refreshes on its own, no matter how much time passes.
Top comments (1)
Nice that you measured it with
next build && next startinstead of dev, most posts on this skip that.One thing worth adding for anyone copying the Bitcoin pattern: the cards take no props, so each one has exactly one cache entry. Once you turn it into a generic
<CoinCard symbol="BTC" />, the props become part of the cache key, which is fine for a symbol but easy to break by accident. Pass in aDate, a timestamp or a whole user object and you get a new entry per request, the hit rate quietly drops to zero and nothing errors. Keeping the cached component's inputs to small primitives is the habit I'd take from this.The other is where the numbers come from in production. The default
'use cache'storage is in memory on each server, and the build ID is part of the key. So with several instances or serverless functions, each one warms its own cache, and every deploy starts cold. The ~40ms will hold on one long-livednext startbox, less so behind a load balancer. The docs point to'use cache: remote'for that case, with the extra network roundtrip it costs.