Partial Prerendering with Cache Components
The 'use cache' directive and Cache Components, and how they let a build-time static shell and per-request streamed data live on the same route.
View the live demo →A static shell and streamed data, on the same route
Streaming (see the previous pattern) fixes how long a page takes to start responding, but every visitor still triggers a fresh render. Cache Components goes a step further: parts of a route that produce the same output for everyone are rendered once, ahead of time, and reused — the same instant-from-a-CDN response a fully static page gets, mixed on the same route with parts that genuinely need to run per request.
Turning it on is one flag in next.config.ts:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;The rendering approach this produces has a name: Partial Prerendering. "Cache Components" is the mechanism ('use cache' plus the config flag); Partial Prerendering is the result — one HTML response with static and dynamic parts stitched together.
Marking what's safe to compute once
The 'use cache' directive caches the return value of an async function or component. Put it at the top of a data-fetching function to cache just the data, or at the top of a component to cache its rendered output:
// components/partial-prerendering/ProductHeader.tsx
import { cacheLife } from "next/cache";
export async function ProductHeader() {
"use cache";
cacheLife("hours");
const product = await getProduct();
return <div>{/* ... */}</div>;
}cacheLife("hours") gives the entry an explicit lifetime — without one, Next.js falls back to an implicit default profile, which is easy to forget is even there. A cached result becomes part of the route's static shell, served straight from a CDN on a direct visit, with no server round trip until the lifetime expires.
Leaving genuinely per-request data out of the cache
Not everything belongs behind 'use cache' — live inventory, a personalization engine, anything expected to differ between requests would just be a stale cache entry with extra steps. That data stays uncached and streams in behind a <Suspense> boundary instead, exactly like the previous pattern:
<ProductHeader />
<Suspense fallback={<LoadingSpinner text="Checking current pricing..." />}>
<PricingPanel />
</Suspense>
<Suspense fallback={<LoadingSpinner text="Finding recommendations..." />}>
<Recommendations />
</Suspense>ProductHeader is cached and needs no boundary — it's already part of the shell. Everything that reads live data sits behind its own boundary instead, the same rule as before: cache what's shared, stream what isn't.
One flag, whole-app validation
cacheComponents isn't scoped to a route — it's a single boolean for the entire app, and once it's on, every route is validated for whether it can render instantly. A route with an uncached dynamic access outside <Suspense> fails the build, not just the route being demoed here.
That's not an all-or-nothing migration, though. export const instant = false on a segment opts it out of validation without opting it out of the flag — it keeps building and serving the old way while the rest of the app adopts the pattern incrementally. Next.js ships a codemod that applies this across a whole tree in one pass:
npx @next/codemod@canary cache-components-instant-false ./src/appThat's exactly how this site adopted the flag: every existing route opted out via the codemod in one commit, and only the demo below was written against Cache Components from the start.
The product demo's structure
Here's how the live demo puts the ideas above together: a cached header that's part of the static shell, and two uncached sections that stream in independently.
app/patterns/partial-prerendering/
├── data.ts
└── product/
├── layout.tsx ← static shell: page chrome, no data access
└── page.tsx ← cached header + two streamed sections
components/partial-prerendering/
├── ProductHeader.tsx ('use cache' — joins the static shell)
├── PricingPanel.tsx (uncached, streams in Suspense)
└── Recommendations.tsx (uncached, streams in Suspense)
components/patterns/loading-spinner.tsx ← shared Suspense fallbackThere are two important pieces here.
The header is cached, not just early in the tree. Unlike a static layout that's part of the shell simply because it never awaits anything, ProductHeader does fetch data — 'use cache' is what makes that fetch's result part of the shell anyway.
Pricing and recommendations never touch the cache. Both stream in behind their own <Suspense> boundary, re-running on every request the same way the previous pattern's sections did.
Open the console on the live demo below and reload a few times — the product catalog log line shows up far less often than the pricing and recommendations lines next to it, which is the cache entry being reused instead of recomputed.