John Aleman

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 →
t: "cache components"

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.

t: "use cache"

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.

t: "streaming uncached data"

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.

t: "adoption"

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/app

That'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.

t: "example"

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 fallback

There 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.