
Next.js Caching
If you learned Next.js caching a year or two ago, forget most of it. Up through Next.js 15, caching in the App Router was a tangle of overlapping mechanisms — the fetch cache, the full route cache, the Router cache, unstable_cache, and a route segment config (export const dynamic, export const revalidate) that quietly decided whether your entire page was static or dynamic. Getting it wrong meant either serving stale data to everyone or accidentally making a static page dynamic and losing all the performance benefits you built the app for in the first place.
Next.js 16 replaces that whole model with Cache Components, and this page — despite still being called "Caching" — is really describing a different mental model for rendering, not just a cache. Instead of caching being a side effect of fetch calls and route-level config, caching becomes an explicit, per-function decision you make with a directive: 'use cache'. Once you understand that one directive and how it interacts with <Suspense>, the rest of this system falls into place. This article walks through that model as it exists in the current docs, with the reasoning and gotchas the reference page doesn't spell out.
Enabling Cache Components
None of what follows applies unless you opt in. Cache Components is a flag in next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
This is an all-or-nothing switch for your app, not something you enable per route. Once it's on, every route in your App Router is expected to produce a static shell at build time — more on that shortly — and the rules in this article apply everywhere. If you're maintaining an older project that hasn't adopted Cache Components yet, the docs point you at a separate "Caching and Revalidating (Previous Model)" guide instead; that's the fetch-cache/unstable_cache system this article deliberately does not cover, because it's being phased out.
One side effect worth knowing about: with Cache Components enabled, GET Route Handlers follow the same prerendering rules as pages. If you have API routes that fetch data on every request, you'll want to read this article with that in mind — the same caching decisions apply there too.
The use cache directive
'use cache' is a directive, syntactically identical to 'use client' or 'use server'. You put it as the first line inside an async function or component, and Next.js caches that function's return value.
import { cacheLife } from "next/cache";
export async function getUsers() {
"use cache";
cacheLife("hours");
return db.query("SELECT * FROM users");
}
Two things happen here that are easy to skim past. First, the directive alone doesn't tell Next.js how long to keep the result — that's cacheLife()'s job, and if you omit it, an implicit default profile applies. I'd treat that as a footgun rather than a convenience: the default profile's actual revalidate/expire numbers are defined by Next.js, not by you, and they can change between versions. If a function is worth caching, it's worth spending one line deciding how long that cache should live. Don't rely on the implicit default for anything you actually care about.
Second, arguments and any values captured from the surrounding closure become part of the cache key. Call getUser(1) and getUser(2) and you get two separate cache entries, not one shared one. This is what makes per-user or per-parameter caching work without you having to build a cache key by hand, but it also means a cached function that closes over something like a timestamp or a random session token will silently generate a new cache entry on every call — effectively caching nothing while looking like it's caching something.
You can apply 'use cache' at two levels, and picking the right one matters more than the docs let on.
Data-level caching
import { cacheLife } from "next/cache";
export async function getUsers() {
"use cache";
cacheLife("hours");
return db.query("SELECT * FROM users");
}
Cache the data-fetching function itself when the same data feeds more than one component, or when you want the caching decision to live next to the query rather than next to the JSX. This is the version I default to for anything shared — a getProducts() used by both a listing page and a "related products" widget, for instance. One cached function, one source of truth for freshness.
UI-level caching
import { cacheLife } from "next/cache";
export default async function Page() {
"use cache";
cacheLife("hours");
const users = await db.query("SELECT * FROM users");
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
Cache the whole component or page when the rendered output is what you want to reuse, not just the underlying data — think a blog post body, a pricing table, or a marketing page's hero section. The tradeoff is granularity: if a UI-level cached component renders five different pieces of data and only one of them changes, you still invalidate and re-render the whole thing. Data-level caching lets you invalidate more surgically.
There's also a file-scoped shortcut worth knowing: put 'use cache' at the top of a file (not inside a function), and every exported function in that file gets cached. Convenient for a file that's entirely made of query functions; risky if you add a function to that file later and forget it inherits the directive.
What happens when you don't cache: streaming with Suspense
Not everything should be cached. A component that needs to reflect the current instant — a live inventory count, a "last updated 3 seconds ago" ticker — should never have 'use cache' on it. For that case, Cache Components wants you to wrap the uncached part in <Suspense>:
import { Suspense } from "react";
async function LatestPosts() {
const data = await fetch("https://api.example.com/posts");
const posts = await data.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export default function Page() {
return (
<>
<h1>My Blog</h1>
<Suspense fallback={<p>Loading posts...</p>}>
<LatestPosts />
</Suspense>
</>
);
}
The <p>Loading posts...</p> fallback ships as part of the prerendered static shell. The real content — the actual fetch — runs at request time and streams in afterward. This is the fundamental trade Cache Components asks you to make explicit: either a piece of UI is cached (and therefore can be part of the static shell), or it's uncached and wrapped in <Suspense> (and therefore streams in later). There's no silent third option anymore where a fetch call without special config just happens to make the whole route dynamic — Next.js now requires you to pick one of these paths, and if you don't, it tells you so. Skip a <Suspense> boundary around something that reads live data, and the dev overlay flags it with a "blocking-route" insight rather than letting your entire route quietly become dynamic the way it used to.
One nuance worth internalizing: <Suspense> gives you a fallback UI for async work, but it doesn't by itself make anything dynamic. A component that only does synchronous work will complete during prerendering and end up in the static shell regardless of whether you wrapped it in <Suspense> — the wrapper doesn't force streaming, it just gives you an escape hatch when streaming is unavoidable.
Working with runtime APIs
cookies(), headers(), searchParams, and dynamic route params are grouped together in the docs as "runtime APIs" — the common thread is that all four only have real values once an actual request comes in. You can't know a visitor's cookies at build time, so any component reading them can't be part of the static build-time shell; it has to run at request time.
import { cookies } from "next/headers";
import { Suspense } from "react";
async function UserGreeting() {
const cookieStore = await cookies();
const theme = cookieStore.get("theme")?.value || "light";
return <p>Your theme: {theme}</p>;
}
export default function Page() {
return (
<>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading...</p>}>
<UserGreeting />
</Suspense>
</>
);
}
Just like the streaming example above, skip the <Suspense> boundary here and you'll hit the same blocking-route warning. This is intentional and, once you're used to it, genuinely useful — it turns "why is this whole page suddenly dynamic" from a debugging session into a compile-time-ish nudge.
For params specifically, there are two better options than eating the runtime cost on every request: generateStaticParams prerenders the specific values you already know about at build time, and for the ones you don't know in advance, "ISR with Cache Components" lets you serve a generic App Shell immediately and fill in the specific version in the background after the first visit.
The middle ground: caching runtime-dependent data
Here's where the model gets genuinely interesting. Say you want to cache something that's derived from a cookie — a user's session-specific dashboard data, for example. You have two options.
The first is 'use cache: private', a variant directive built for exactly this: it gives a cache lifetime to a function that reads cookies, headers, or searchParams directly.
The second — and the one the docs walk through in more detail — is to read the runtime value in an uncached component and pass it as an argument into a separate cached function:
import { cookies } from "next/headers";
import { Suspense } from "react";
export default function Page() {
return (
<Suspense fallback={<div>Loading...</div>}>
<ProfileContent />
</Suspense>
);
}
// Not cached — reads runtime data directly
async function ProfileContent() {
const session = (await cookies()).get("session")?.value;
return <CachedContent sessionId={session} />;
}
// Cached — receives the extracted value as a prop
async function CachedContent({ sessionId }: { sessionId: string }) {
"use cache";
// sessionId becomes part of the cache key
const data = await fetchUserData(sessionId);
return <div>{data}</div>;
}
The split matters: ProfileContent can never be prerendered because it touches cookies() directly, but CachedContent can be cached per sessionId, so two requests from the same logged-in user hit the same cache entry instead of re-fetching every time.
One catch that's easy to miss: because CachedContent is gated behind request data, it never makes it into the build-time static shell. At runtime it's cached in-memory by default — which, on serverless platforms, does not persist across invocations, so in practice it may re-fetch far more often than the cacheLife you configured would suggest. If that in-memory, per-instance cache isn't good enough — say you're running on serverless and want the cache to actually survive between cold starts — reach for 'use cache: remote', which stores the result in a durable, shared cache handler instead.
This same pattern is also what makes per-link prefetching smarter, which I'll come back to below.
Putting it together: static, cached, and streaming on one page
This is the example from the docs that ties the whole model together, and it's worth sitting with because it shows three different rendering strategies coexisting on a single route without you writing any route-level config to switch between them:
import { Suspense } from "react";
import { cookies } from "next/headers";
import { cacheLife, cacheTag } from "next/cache";
import Link from "next/link";
export default function BlogPage() {
return (
<>
{/* Static content — prerendered automatically */}
<header>
<h1>Our Blog</h1>
<nav>
<Link href="/">Home</Link> | <Link href="/about">About</Link>
</nav>
</header>
{/* Cached dynamic content — included in the static shell */}
<BlogPosts />
{/* Runtime dynamic content — streams at request time */}
<Suspense fallback={<p>Loading your preferences...</p>}>
<UserPreferences />
</Suspense>
</>
);
}
type Post = { id: string; title: string; author: string; date: string };
// Everyone sees the same blog posts (revalidated every hour)
async function BlogPosts() {
"use cache";
cacheLife("hours");
cacheTag("posts");
const res = await fetch("https://api.vercel.app/blog");
const posts: Post[] = await res.json();
return (
<section>
<h2>Latest Posts</h2>
<ul>
{posts.map((post) => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>
By {post.author} on {post.date}
</p>
</li>
))}
</ul>
</section>
);
}
// UI that depends on a value stored in cookies
async function UserPreferences() {
const theme = (await cookies()).get("theme")?.value || "light";
const favoriteCategory = (await cookies()).get("category")?.value;
return (
<aside>
<p>Your theme: {theme}</p>
{favoriteCategory && <p>Favorite category: {favoriteCategory}</p>}
</aside>
);
}
Notice that reading cookies() inside UserPreferences doesn't drag the rest of the page down with it. Under the old model, a single cookies() call anywhere in a route's render tree could flip the entire route to dynamic rendering. Here, the header and the blog posts still ship instantly as part of the static shell, and only the <Suspense>-wrapped preferences box waits for the request. That's the headline improvement Cache Components is selling: dynamic and static content stop being all-or-nothing at the route level and become a per-component decision.
Worth noting while you're building pages like this: errors get the same subtree-scoped treatment as async work. Wrap a subtree that might throw during rendering in an error boundary — catchError for component-level boundaries, or an error.js file for route-level ones — the same way <Suspense> contains a pending async call.
Random values and timestamps need explicit handling
Math.random(), Date.now(), and crypto.randomUUID() are the classic footguns in any caching system, because they silently produce a different value every time they run, and if that value gets baked into a cached or prerendered result, every user ends up seeing whatever value happened to run at build time.
Cache Components refuses to let this happen by accident — it requires you to make a choice. If you want a genuinely unique value per request, defer to request time with connection() and wrap it in <Suspense>:
import { connection } from "next/server";
import { Suspense } from "react";
async function UniqueContent() {
await connection();
const uuid = crypto.randomUUID();
return <p>Request ID: {uuid}</p>;
}
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<UniqueContent />
</Suspense>
);
}
Or, if you actually want the same value shared across everyone until the cache revalidates — a build identifier is the example the docs use — cache it instead:
export default async function Page() {
"use cache";
const buildId = crypto.randomUUID();
return <p>Build ID: {buildId}</p>;
}
You don't need to memorize which functions count as "random" — the dev overlay names the exact offending call (blocking-prerender-random, blocking-prerender-current-time, blocking-prerender-crypto) and suggests one of these two fixes. One explicit exception: performance.now() is treated as safe, because it's meant for telemetry rather than rendered output — you're expected to send it to a logger, not put it in the JSX.
Predictable values don't need any of this
On the other end of the spectrum, module-level imports, synchronous file reads, and pure computation are treated as safe to prerender automatically, with no directive required:
import fs from "node:fs";
export default async function Page() {
const constants = await import("./constants.json");
const content = fs.readFileSync("./config.json", "utf-8");
const items = JSON.parse(content).items ?? [];
return (
<div>
<h1>{constants.appName}</h1>
<ul>
{items.map((item) => (
<li key={item.id}>{item.value}</li>
))}
</ul>
</div>
);
}
This extends to embedded databases with synchronous APIs, like better-sqlite3 or Node's built-in node:sqlite. If you genuinely need per-request freshness from a synchronous source like that, call connection() before the query to opt back into runtime behavior.
There's also a subtlety about where you read a resource, not just how. If a file or config is the same on every request — fonts, static config, anything not derived from the incoming request — reading it at module scope (outside the component function entirely) is simpler than wrapping it in use cache:
import { readFile } from "node:fs/promises";
const content = await readFile("./config.json", "utf-8");
const items = JSON.parse(content).items ?? [];
export default function Page() {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.value}</li>
))}
</ul>
);
}
Calling readFile() from inside the component, by contrast, is treated as an uncached async read that must either sit behind use cache or a <Suspense> boundary. The rule of thumb: if the data doesn't depend on the request and won't change during the life of your server process, module scope is the least amount of machinery you can use to get it right.
Prerendering, the static shell, and the App Shell
Zoom out and here's the actual mental model Cache Components is built on. At build time, Next.js walks your route's component tree and, for each component, decides how to handle it based on what it does:
use cache→ the result is cached and folded into the static shell (assuming its lifetime isn't too short to survive prerendering)<Suspense>→ the fallback goes into the static shell; the real content streams in at request time- Predictable values → complete during prerender automatically
- Random values/timestamps → require
connection()+<Suspense>, oruse cache, explicitly
What comes out the other end is a static shell: HTML for a direct page load, plus a serialized RSC payload for client-side navigations. Both can be served straight from a CDN with no round trip to your origin server — this is what makes direct navigations to a Cache Components route effectively instant, and it's the whole point of the exercise. This rendering strategy has a name: Partial Prerendering (PPR), and it's the default behavior once Cache Components is on.
For routes with dynamic segments — [slug], for example — where you haven't told Next.js the value in advance via generateStaticParams, there's a related concept called the App Shell: a reusable, URL-independent version of the static shell with the param-specific parts left behind their <Suspense> fallbacks. ISR (below) is what upgrades the App Shell into the fully concrete page after the first real visitor hits it.
Maximizing the static shell: push async work down the tree
This is, in my opinion, the single most useful structural habit Cache Components rewards, and it's easy to get backwards without realizing it. Consider a layout that destructures a dynamic params value right at the top:
export default async function Layout({
children,
params,
}: LayoutProps<"/shop/[slug]">) {
const { slug } = await params;
return (
<div>
<Sidebar />
<h1>{slug}</h1>
{children}
</div>
);
}
If slug isn't one of the values covered by generateStaticParams, awaiting params here makes the entire layout runtime-dependent — including the <Sidebar />, which has nothing to do with the URL and could easily have been static. The fix is to stop awaiting at the layout level and push the await down into a small leaf component wrapped in <Suspense>:
import { Suspense } from "react";
// Not async: this layout never awaits params
export default function Layout({
children,
params,
}: LayoutProps<"/shop/[slug]">) {
return (
<div>
<Sidebar />
<Suspense fallback={<h1>Loading...</h1>}>
{/* await happens inside the boundary, so the shell still renders */}
{params.then(({ slug }) => (
<SlugHeading slug={slug} />
))}
</Suspense>
{children}
</div>
);
}
function SlugHeading({ slug }: { slug: string }) {
return <h1>{slug}</h1>;
}
Now <Sidebar /> and {children} are back in the static shell, and only the one <h1> that actually needs the slug streams in. The same principle applies to cookies(), headers(), searchParams, and any data fetch: the deeper in the tree the runtime-dependent work happens, the smaller the piece of your page that has to wait for the request. In practice, this means resisting the urge to fetch everything at the top of a page component "to keep things simple" — that instinct is exactly backwards under this model, and it's the most common way I've seen teams accidentally undo the benefits of Cache Components after adopting it.
Instant navigation and prefetching
Direct visits get the static-shell treatment described above for free. Client-side navigations — clicking a <Link> — are handled separately, and Cache Components validates those too: it checks that the <Suspense> structure that worked for a direct visit also holds up during a transition, and flags it with the same kind of insight if it doesn't.
With Partial Prefetching enabled, the router prefetches each route's App Shell by default — static content plus session data from cookies()/headers(). If you also want content that depends on the link's own URL data (searchParams, dynamic params) prefetched, set prefetch={true} on that specific <Link>. Doing so triggers a real server invocation per prefetchable link — it's not free, but it means a cached function fed by extracted runtime values (the sessionId pattern from earlier) can resolve before the user even clicks, because the destination URL is already known at prefetch time.
Where cached content actually lives
This is the part most people gloss over until a cache doesn't behave the way they expected in production. A cached function's output becomes a serialized RSC payload, and where that payload ends up depends on how it was produced:
- Prerendered HTML — built at build time (or after an ISR upgrade) and stored on disk when self-hosting, or your platform's durable storage behind a CDN.
revalidateandexpirefromcacheLifecontrol when this gets rebuilt. - A shared, in-memory store — the default for anything cached at runtime rather than build time. This is per server instance and ephemeral on serverless, meaning it does not survive between invocations on most serverless platforms.
use cache: remoteis the escape hatch: it moves the result into a durable, shared cache handler, at the cost of a network round trip that's only worth it at a high cache hit rate. - The browser — payloads included in an RSC response for a navigation or prefetch live here, kept fresh for the
cacheLifestalewindow.use cache: privateresults live only here, never on the server.
The practical takeaway: if you deploy to a serverless platform and your use cache function isn't behaving like it's cached — it seems to re-run on every request — check whether you actually needed use cache: remote instead of the default in-memory store. And regardless of which store you're using, all of them are scoped to a single deployment: a new deploy starts every cache from empty, because the cache key includes the build id. Don't expect a cache warmed under yesterday's deploy to still be warm today.
Incremental Static Regeneration, briefly
For dynamic-segment routes, generateStaticParams prerenders the specific URLs you list at build time; anything else gets the App Shell instantly on first visit, then gets upgraded in the background and cached for the next visitor. This is Incremental Static Regeneration, and it's covered in far more depth in its own guide — worth reading in full if you're building anything with a large or unbounded set of dynamic pages (a product catalog, user profiles), since the interaction between ISR and Cache Components has its own set of edge cases around what counts as "known" versus "unknown" params.
Bots and crawlers get the slow path
One easy-to-miss detail: the instant static shell described throughout this article is what browsers get. Bots and crawlers, detected by user agent, are treated differently — Next.js skips the shell entirely for them and renders the whole page dynamically at request time, only sending the finished HTML once that render completes. This is a deliberate trade-off; a partial shell with streaming placeholders is useless to a crawler that can't wait around for JavaScript to fill in the gaps.
The gotcha: because the page re-renders from scratch for a bot instead of reusing the shell, anything your shell depended on that only exists during prerendering — build-time-only data, values unreachable in the request-time environment — can quietly break for crawlers even though it works fine for every human visitor. If SEO matters for a route, make sure whatever data the shell relies on is also genuinely available at request time, not just at build time.
Key Takeaways
Cache Components turns a route's caching behavior from a single implicit setting into an explicit, per-component decision. The table below is the fastest way to decide which tool a given piece of UI needs:
| Situation | What to reach for |
|---|---|
| Data or UI that's the same for everyone, for a while | 'use cache' + cacheLife(...) |
| Data that must be current on every request | No cache directive, wrapped in <Suspense> |
Reads cookies(), headers(), searchParams, or params directly | <Suspense> around the component that reads it |
| Cacheable data that also needs a runtime value (e.g. session ID) | Read the runtime value in an uncached component, pass it as a prop into a cached one |
| Runtime-derived data, cached anyway | 'use cache: private' |
| Cache needs to survive across serverless instances/cold starts | 'use cache: remote' instead of the default in-memory store |
Math.random(), Date.now(), crypto.randomUUID() | connection() + <Suspense> for a unique value, or 'use cache' to share one |
| Static config/fonts/local files that never change per request | Read at module scope, not inside the component |
| Dynamic route with unknown params ahead of time | generateStaticParams for the known ones, ISR for the rest |
If there's one habit worth carrying out of this article, it's the "maximize the static shell" principle: push runtime-dependent reads as far down your component tree as they'll go, and let everything above them stay static. Cache Components will tell you, loudly, when you've gotten this wrong — but it's a lot more pleasant to design for it upfront than to chase blocking-route warnings after the fact.


