Dev Logs
/Next.js/ Chapter 16: Cache Components & Partial Prerendering
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
  • 16Chapter 16: Cache Components & Partial Prerendering
    • Plain English Explanation
    • Enabling it
    • The mental model
    • The rule this creates
    • A complete page
    • Maximizing the static shell
    • The problem
    • The fix
    • Random values and timestamps
    • Unique per request
    • Or shared across all users
    • Predictable values are free
    • The App Shell
    • Runtime prefetching
    • Bots and crawlers
    • Migrating an existing app
    • Common Pitfalls
    • . Treating cacheComponents as a rename of experimental.ppr
    • . Awaiting request data at the top of a layout
    • . Uncached data with no Suspense boundary
    • . Date.now() in a prerendered component
    • . cacheLife('seconds') in the shell
    • . Suspense with the await still in the parent
    • . Shell data unavailable at request time
    • . Enabling it and not fixing the errors
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Sort the buckets
    • Problem 2: Rescue the shell
    • Problem 3: Design the page
    • Problem 4: Debug the bot
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🧩 Chapter 16: Cache Components & Partial Prerendering

The rendering model Next.js 16 is built around: one page that's static and dynamic at the same time.

📖 Plain English Explanation

For a decade, web rendering forced a choice per page.

Static — build the HTML ahead of time, serve it from a CDN. Instant, cheap, but the same for everyone and stale the moment data changes.

Dynamic — render on every request. Always fresh, personalized, but every visitor waits for your server.

Real pages don't fit either box. Take a product page:

┌─────────────────────────────────┐
│ Logo, nav, footer               │  ← identical for everyone, forever
│ Product name, images, specs     │  ← same for everyone, changes rarely
│ Reviews                         │  ← same for everyone, changes hourly
│ ─────────────────────────────── │
│ "3 left in stock"               │  ← live, per request
│ "In your cart"                  │  ← personalized
└─────────────────────────────────┘

Under the old model, one personalized element forced the entire page to render dynamically. Your logo — a string that hasn't changed since 2019 — got re-rendered on every request, because the cart badge next to it needed to be fresh.

Partial Prerendering fixes that. It renders one page as:

  • A static shell — everything knowable ahead of time, served instantly from a CDN
  • Dynamic holes — the request-specific parts, streamed in behind Suspense fallbacks

One URL. One request. Instant first paint, fully personalized content.

In Next.js 16, PPR isn't a separate feature you enable per route. It's what Cache Components does. Turn on cacheComponents: true and every route gets this model.

⚙️ Enabling it

ts
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
js
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  cacheComponents: true,
}

module.exports = nextConfig

⚠️ Changed in Next.js 16

Everything about how PPR is enabled changed.

js
// ❌ all removed
experimental: { ppr: true }
experimental: { dynamicIO: true }
experimental: { useCache: true }
tsx
// ❌ the per-route segment config is gone too
export const experimental_ppr = true
js
// ✅ one flag, applies everywhere
cacheComponents: true

This is not a rename. PPR in Next.js 16 works differently from the Next.js 15 canaries. If you're using experimental.ppr today, the guidance is to stay on your current Next.js 15 canary until you can migrate properly.

The version-16 codemod removes experimental_ppr from your route files, but the config change and the code changes are on you.

🧠 The mental model

At build time, Next.js renders your component tree and sorts each part into one of three buckets:

┌─────────────────────────────────────────────────────┐
│  1. PREDICTABLE  →  goes in the static shell        │
│     module imports, pure computation,               │
│     fs.readFileSync, synchronous work               │
├─────────────────────────────────────────────────────┤
│  2. CACHED       →  goes in the static shell        │
│     anything inside 'use cache'                     │
│     (unless its lifetime is too short)              │
├─────────────────────────────────────────────────────┤
│  3. DYNAMIC      →  becomes a hole                  │
│     cookies(), headers(), searchParams,             │
│     uncached fetches, Date.now(), Math.random()     │
│     → the Suspense fallback ships in the shell,     │
│       real content streams at request time          │
└─────────────────────────────────────────────────────┘

The output is a static shell — HTML plus a serialized RSC payload — that can sit on a CDN. A request gets that shell immediately, and the dynamic holes fill in as the server resolves them.

The rule this creates

Every uncached data access must be inside a <Suspense> boundary.

That's the whole discipline. Next.js enforces it: an uncached read that isn't behind a boundary is a build error, because it would block the entire shell and defeat the point.

🏗️ A complete page

tsx
// app/blog/page.tsx
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 (
    <>
      {/* 1. Static — prerendered automatically */}
      <header>
        <h1>Our Blog</h1>
        <nav>
          <Link href="/">Home</Link> | <Link href="/about">About</Link>
        </nav>
      </header>

      {/* 2. Cached — also part of the static shell */}
      <BlogPosts />

      {/* 3. Dynamic — fallback ships in the shell, content streams */}
      <Suspense fallback={<p>Loading your preferences…</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}

async function BlogPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')

  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()

  return (
    <section>
      <h2>Latest Posts</h2>
      <ul>
        {posts.map((post: Post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </section>
  )
}

async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value ?? 'light'
  return <aside>Your theme: {theme}</aside>
}
jsx
// app/blog/page.js
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 (
    <>
      <header>
        <h1>Our Blog</h1>
        <nav>
          <Link href="/">Home</Link> | <Link href="/about">About</Link>
        </nav>
      </header>

      <BlogPosts />

      <Suspense fallback={<p>Loading your preferences…</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}

async function BlogPosts() {
  'use cache'
  cacheLife('hours')
  cacheTag('posts')

  const res = await fetch('https://api.example.com/posts')
  const posts = await res.json()

  return (
    <section>
      <h2>Latest Posts</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </section>
  )
}

async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value ?? 'light'
  return <aside>Your theme: {theme}</aside>
}

The header and blog posts are in the static shell. The preferences fallback is too — and the real preferences stream in at request time.

Here's the change that matters: reading cookies() no longer opts the whole route into dynamic rendering. Under the old model, one cookies() call anywhere made the entire page dynamic. Now it makes exactly the component that reads it dynamic, and everything else still ships as static HTML.

📏 Maximizing the static shell

The core skill of Cache Components: the deeper your async work sits, the more of the page can be prerendered.

The problem

tsx
// app/shop/[slug]/layout.tsx
export default async function Layout({
  children,
  params,
}: LayoutProps<'/shop/[slug]'>) {
  const { slug } = await params      // ← awaited at the top

  return (
    <div>
      <Sidebar />
      <h1>{slug}</h1>
      {children}
    </div>
  )
}

If slug isn't in generateStaticParams, it's runtime data. The await is at the top of the layout, so nothing below it can be prerendered — not the sidebar, not the children. You've lost the shell for one heading.

The fix

tsx
// app/shop/[slug]/layout.tsx
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>}>
        {/* the await happens inside the boundary */}
        {params.then(({ slug }) => (
          <SlugHeading slug={slug} />
        ))}
      </Suspense>
      {children}
    </div>
  )
}

function SlugHeading({ slug }: { slug: string }) {
  return <h1>{slug}</h1>
}
jsx
// app/shop/[slug]/layout.js
import { Suspense } from 'react'

export default function Layout({ children, params }) {
  return (
    <div>
      <Sidebar />
      <Suspense fallback={<h1>Loading…</h1>}>
        {params.then(({ slug }) => (
          <SlugHeading slug={slug} />
        ))}
      </Suspense>
      {children}
    </div>
  )
}

function SlugHeading({ slug }) {
  return <h1>{slug}</h1>
}

Now <Sidebar />, {children}, and the fallback are all in the shell. Only the heading streams.

The same principle applies to cookies(), headers(), searchParams, and every uncached fetch. Push the await down. Pass the promise, don't resolve it.

🎲 Random values and timestamps

Math.random(), Date.now(), new Date(), and crypto.randomUUID() return something different every call. Prerendering them would freeze one value into the static HTML forever. Cache Components makes you choose.

Unique per request

Call connection() first to opt out of prerendering, and wrap in Suspense:

tsx
import { connection } from 'next/server'
import { Suspense } from 'react'

async function RequestId() {
  await connection()                    // "wait for a real request"
  const id = crypto.randomUUID()
  return <p>Request ID: {id}</p>
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <RequestId />
    </Suspense>
  )
}
jsx
import { connection } from 'next/server'
import { Suspense } from 'react'

async function RequestId() {
  await connection()
  const id = crypto.randomUUID()
  return <p>Request ID: {id}</p>
}

export default function Page() {
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <RequestId />
    </Suspense>
  )
}

Or shared across all users

tsx
export default async function Page() {
  'use cache'
  const buildId = crypto.randomUUID()    // one value until revalidation
  return <p>Build ID: {buildId}</p>
}

You don't have to memorize which calls trigger this. The dev overlay surfaces a blocking-prerender-random, blocking-prerender-current-time, or blocking-prerender-crypto insight naming the exact call and offering the fixes.

✅ Predictable values are free

Not everything async is dynamic. Module imports, synchronous I/O, and pure computation produce the same result every time, so they're prerendered automatically:

tsx
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((i: Item) => <li key={i.id}>{i.value}</li>)}</ul>
    </div>
  )
}

No use cache, no Suspense — this is entirely in the static shell. That includes synchronous embedded databases like better-sqlite3 and node:sqlite.

For async local reads that don't depend on the request — a config file, a font — read them once at module scope instead of during rendering:

tsx
import { readFile } from 'node:fs/promises'

// module scope — runs once, not per render
const content = await readFile('./config.json', 'utf-8')
const items = JSON.parse(content).items ?? []

export default function Page() {
  return <ul>{items.map((i) => <li key={i.id}>{i.value}</li>)}</ul>
}

Calling await readFile() inside the component would be treated as uncached data needing use cache or a Suspense boundary.

🐚 The App Shell

A term you'll see in Next.js 16 docs and error messages.

For a route like /shop/[slug], there are two flavors of prerendered output:

  • Static shell — when the param is known (it was in generateStaticParams), the shell contains that concrete content
  • App Shell — when the param isn't known, this is the reusable, URL-independent version: the same shell with param-specific parts left behind their fallbacks

The App Shell is what makes the long tail fast. A visitor to an ungenerated URL gets the App Shell instantly from the CDN, and the concrete content fills in behind it — then gets cached for the next visitor. That's ISR with Cache Components, from Chapter 15.

One nuance: an App Shell that reads cookies() or headers() is session-specific, so it's cached per session in the browser rather than in the shared server cache.

⚡ Runtime prefetching

Chapter 4 introduced prefetching. Cache Components extends it.

With Partial Prefetching enabled:

ts
// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

...the router prefetches each route's App Shell, including session data derived from cookies() and headers(). Runtime prefetching goes further: with <Link prefetch={true}>, Next.js re-renders the destination's tree at prefetch time with the destination URL resolved — so searchParams and params are in scope.

tsx
// app/search/page.tsx
import { Suspense } from 'react'

export default function SearchPage(props: PageProps<'/search'>) {
  return (
    <Suspense fallback={<p>Loading results…</p>}>
      <Results searchParams={props.searchParams} />
    </Suspense>
  )
}

async function Results({
  searchParams,
}: Pick<PageProps<'/search'>, 'searchParams'>) {
  const { q } = await searchParams
  const results = await search(q)
  return <ul>{results.map((r) => <li key={r.id}>{r.title}</li>)}</ul>
}

async function search(query: string | string[] | undefined) {
  'use cache'
  return db.search(query)
}

On a direct visit, <Results> streams in behind the fallback. But when a <Link href="/search?q=shoes"> is prefetched, the framework resolves q=shoes from the link's URL, runs the cached search, and includes the result in the prefetch — before the click. The navigation has nothing to wait for.

The cost is one server invocation per prefetchable link. Worth it for high-intent links; not for a table of 500 rows.

🤖 Bots and crawlers

Worth knowing, because it can produce a bug you'd never see in a browser.

Bots are detected by user agent and handled differently: they need a complete document, so Next.js skips the shell and renders the entire page dynamically at request time, sending finished HTML once the render completes.

Which means work that happened during prerendering now runs at request time for a bot. If part of your shell depends on something only available at build time — a build-time-only env var, a file that isn't deployed — the page works for a human and fails for Googlebot.

Make sure the data your shell relies on is also reachable at request time.

🔀 Migrating an existing app

Turning on cacheComponents in a real codebase surfaces errors. That's the point — it's telling you where your page can't be prerendered.

A workable order:

1. Enable the flag and run next build. Expect failures.

2. Read the error. It names the route and lists your options:

Error: Route "/blog/[slug]": Next.js encountered uncached or runtime
data during prerendering.

`fetch(...)`, `cookies()`, `headers()`, `params`, `searchParams`, or
`connection()` accessed outside of `<Suspense>` prevents the route from
being prerendered, blocking the page load and leading to a slower user
experience.

Ways to fix this:
  - [stream] Provide a placeholder with `<Suspense fallback={...}>`
  - [cache]  Cache the access with `"use cache"`
  - [block]  Set `export const instant = false` to allow a blocking route

3. Pick a fix:

tsx
// Fix A — cache it (data is shared and can be stale)
async function Prices() {
  'use cache'
  cacheLife('hours')
  return <PriceList prices={await getPrices()} />
}

// Fix B — stream it (data must be fresh or is per-user)
<Suspense fallback={<PriceSkeleton />}>
  <Prices />
</Suspense>

// Fix C — push the await down (the read can happen deeper)
// see "Maximizing the static shell" above

// Fix D — the escape hatch: accept a blocking route
export const instant = false

Use instant = false sparingly. It tells Next.js "this route legitimately can't prerender, stop complaining" — which is occasionally true (a route that's nothing but per-user data), but is mostly a way to silence the error without fixing it. A codebase where every route sets it has cacheComponents enabled and gets nothing from it.

Also note this build error can surface only under next start on a dynamically rendered route, so a passing next build isn't proof.

4. Move awaits down the tree. Layouts that await params or cookies() at the top are the biggest wins.

5. Remove legacy route segment configs. dynamicParams is rejected outright:

Error: Route segment config "dynamicParams" is not compatible with
`nextConfig.cacheComponents`. Please remove it.

Delete it — Cache Components handles unlisted params through the App Shell. To 404 an invalid param, validate it and call notFound() (Chapter 5).

6. Add cacheLife to every use cache. Not optional in a real codebase — implicit lifetimes are how you get surprising behavior later.

7. Check the dev overlay. The blocking-route insight names the route and links to a walkthrough. It's genuinely good; read it rather than guessing.

8. Test production and check a bot user agent.

Don't try to do this in one sitting on a large app. Enable it on a branch, work through the errors route by route.

⚠️ Common Pitfalls

1. Treating cacheComponents as a rename of experimental.ppr

It isn't. The model is different. Flipping the flag on an app built for Next.js 15 PPR will break the build, and that's intentional.

2. Awaiting request data at the top of a layout

The single biggest destroyer of static shells. Push it down, or wrap it in Suspense.

3. Uncached data with no Suspense boundary

Insight: blocking-route

Cache it, stream it, or move the read deeper. Those are the only three options.

4. Date.now() in a prerendered component

tsx
// ❌ blocking-prerender-current-time
export default function Page() {
  return <p>Rendered at {new Date().toISOString()}</p>
}

// ✅ per request
async function Timestamp() {
  await connection()
  return <p>{new Date().toISOString()}</p>
}

5. cacheLife('seconds') in the shell

A seconds profile has a 1-minute expire, which excludes it from prerenders — it becomes a dynamic hole. Fine if intentional; confusing if not.

6. Suspense with the await still in the parent

Chapter 8's pitfall, and it matters more here. If the await is in the parent, the boundary buys you nothing and the shell still blocks.

7. Shell data unavailable at request time

The bot-only failure described above. Check it deliberately:

bash
curl -A "Googlebot" http://localhost:3000/your-page

8. Enabling it and not fixing the errors

cacheComponents with every route opted out of prerendering is strictly worse than not enabling it. Either do the work or leave it off.

🎯 When & Why to Use

Enable cacheComponents when:
  ✅ Pages mix shared content with personalized content
  ✅ You want CDN-speed first paint without giving up personalization
  ✅ You're starting a new Next.js 16 project — start here
  ✅ You're already using use cache / cacheLife / cacheTag

Hold off when:
  ❌ Every page is fully static (a docs site) — you already have the shell
  ❌ Every page is fully dynamic behind auth with nothing shared
  ❌ You're mid-migration from Next.js 15 PPR — plan it properly
  ❌ You can't spend the time to fix the build errors it surfaces

🏋️ Mini Practice Problems

Problem 1: Sort the buckets

Static shell, cached, or dynamic hole?

tsx
export default async function Page() {
  const config = await import('./config.json')          // A
  const posts = await getCachedPosts()                  // B ('use cache')
  const theme = (await cookies()).get('theme')          // C
  const now = Date.now()                                // D
  const version = process.env.NEXT_PUBLIC_VERSION       // E
  const live = await fetch(API, { cache: 'no-store' })  // F
}

Which need a Suspense boundary?

Problem 2: Rescue the shell

Nothing on this page prerenders. Fix it so the nav and footer are static.

tsx
export default async function Layout({ children, params }) {
  const { org } = await params
  const session = (await cookies()).get('session')
  const org_data = await getOrg(org)

  return (
    <div>
      <Nav />
      <h1>{org_data.name}</h1>
      <p>Signed in as {session?.value}</p>
      {children}
      <Footer />
    </div>
  )
}

Problem 3: Design the page

An airline booking page shows:

  • Header, nav, footer
  • Route info (JFK → LHR), changes monthly
  • Today's fares, updated every few hours
  • Seats remaining, must be live
  • "Your saved trips", per user
  • A booking reference generated per visit

For each: which bucket, and what code makes it so?

Problem 4: Debug the bot

The page works in a browser but Googlebot gets a 500. The shell reads a JSON file that's generated by a build script and written to .next/cache/. Explain why, and give two fixes.

💼 Interview Notes

Common Questions

Q: What is Partial Prerendering? Rendering one page as a static shell served instantly from a CDN, with request-specific parts left as holes that stream in behind Suspense fallbacks. It removes the per-page static-or-dynamic choice.

Q: How is PPR enabled in Next.js 16? Via cacheComponents: true. It replaced experimental.ppr, experimental.dynamicIO, experimental.useCache, and the per-route experimental_ppr segment config. It's not a rename — the underlying model changed.

Q: What decides whether something ends up in the static shell? Predictable work (module imports, pure computation, synchronous I/O) and anything inside a use cache scope with a long enough lifetime. Runtime APIs, uncached fetches, and non-deterministic values become dynamic holes.

Q: Does reading cookies() make the whole route dynamic? Not with Cache Components. It makes the component that reads it dynamic; everything else still prerenders. Under the old model, one call anywhere opted the whole route out.

Q: What does "maximizing the static shell" mean? Moving awaits as deep into the tree as possible. Awaiting params at the top of a layout blocks prerendering of everything below it; passing the promise down and awaiting inside a Suspense boundary keeps the rest of the tree static.

Q: Why does Math.random() need connection()? It returns a different value each call, so prerendering would freeze one value into the static HTML. connection() says "wait for a real request", opting the component out of prerendering. The alternative is use cache, which shares one value across all users.

Q: What's an App Shell? The reusable, URL-independent static shell for a route whose params aren't known at build time. It's served instantly to visitors of ungenerated URLs while the concrete content is filled in behind it and cached.

Q: How are bots handled? By user agent. Because crawlers need a complete document, Next.js skips the shell and renders the full page dynamically, sending finished HTML once it's done. This means shell data must also be available at request time, or the page works for humans and fails for crawlers.

🏢 Asked at Companies

  • Vercel: "Explain PPR to someone who knows SSG and SSR but nothing else."
  • Amazon: "A product page has a live stock counter. How do you keep the rest of it on a CDN?"
  • Shopify: "You enable cacheComponents and 40 routes fail to build. Walk me through your process."
  • Cloudflare: "What are the trade-offs of streaming a static shell versus waiting for a complete render?"

📊 Visual Memory Aid

              ONE PAGE, TWO SPEEDS

  ┌──────────────────────────────────┐
  │ ██████ STATIC SHELL ████████████ │  from the CDN, 0ms
  │ ██ logo  nav  product info ██████ │
  │ ██████████████████████████████████ │
  │ ┌────────────────────────────┐   │
  │ │ ░ dynamic hole ░           │   │  streams at request time
  │ │ "3 left in stock"          │   │
  │ └────────────────────────────┘   │
  │ ┌────────────────────────────┐   │
  │ │ ░ dynamic hole ░           │   │
  │ │ "In your cart"             │   │
  │ └────────────────────────────┘   │
  └──────────────────────────────────┘


              THE THREE BUCKETS

  PREDICTABLE ──► shell    imports, pure fns, sync I/O
  'use cache' ──► shell    (if lifetime isn't too short)
  DYNAMIC     ──► hole     cookies, headers, searchParams,
                           uncached fetch, Date.now(), random


              PUSH THE AWAIT DOWN

  ❌ async Layout() {
       const {slug} = await params     ← blocks EVERYTHING below
       return <><Sidebar/><h1>{slug}</h1>{children}</>
     }
     shell: (nothing)

  ✅ function Layout() {
       return <>
         <Sidebar/>                     ← shell
         <Suspense><Heading p={params}/></Suspense>
         {children}                     ← shell
       </>
     }
     shell: Sidebar + children + fallback


              THE FOUR FIXES

  uncached data blocking the shell?
    ├── cache it     → 'use cache' + cacheLife
    ├── stream it    → wrap in <Suspense>
    ├── move it down → await deeper in the tree
    └── give up      → export const instant = false   ⚠️ escape hatch


              LEGACY CONFIGS THAT NOW ERROR

  export const dynamicParams = …   ← rejected with cacheComponents
       └── delete it; the App Shell covers unlisted params
           to 404, validate the param + notFound()

🎯 Key Takeaways

  1. PPR ends the static-vs-dynamic choice. One page ships a CDN-served static shell with request-specific holes streamed in behind Suspense fallbacks.
  2. cacheComponents: true is how you get it — and it replaced experimental.ppr, dynamicIO, useCache, and experimental_ppr. It is not a rename; the model changed.
  3. Every uncached data access must sit inside a <Suspense> boundary. Next.js enforces this at build time, and the fixes are always: cache it, stream it, move the await deeper — or, as a last resort, export const instant = false.
  4. The deeper your awaits, the bigger your shell. Awaiting params or cookies() at the top of a layout throws away the prerender for everything beneath it.
  5. Reading cookies() no longer poisons the whole route. Only the component reading it becomes dynamic — which is what makes personalized pages fast again.

Next Chapter: Proxy (formerly Middleware) →

Practice: Take an existing page with a personalized header and shared content. Enable cacheComponents, fix every build error, then compare the initial HTML response (curl the URL) before and after — count how much real content arrives in the first chunk.


PreviousChapter 15: Revalidation & ISRNextChapter 17: Proxy (formerly Middleware)

Open source, free forever. Built by iammhador.

Contribute on GitHub