← Jude Gao The App Shell
Chapters ▾
A Next.js field guide · Cache Components

The App Shell

You made the first load instant — Partial Prerendering serves a static shell from the CDN and streams the rest. This is the story of every load after that: what happens when the user clicks a link, and how Cache Components gives client-side navigations a shell of their own.

Here's a fact that's easy to miss, because nothing in the API surface points at it: Partial Prerendering, as you know it, only ever runs for document requests. The static shell you worked so hard to maximize is served when a browser asks for an HTML page — the first visit, a hard refresh, a link from an email. The moment your app hydrates, the user stops making document requests. Every click from then on is a client-side navigation, and client-side navigations play by different rules.

Cache Components extends the shell idea to those clicks. The mechanism is called the App Shell, and by the end of this guide you'll know precisely what's inside one, how the router uses it to paint the next page in roughly zero milliseconds, and how to structure your routes so that shell is worth painting.

Two colors do a lot of work in this guide. Everything green is in a shell — prerendered or cached, ready before the user clicks. Everything orange streams — it needs a server at navigation time and arrives after the click. Every diagram, code sample, and demo below uses them the same way.

In the shell — prerendered or cached, ready before the click Streams in — rendered at request time, arrives after the click

Feel it first

Below is a simulated store. The Blocking navigation mode replays how a client-side navigation to an un-prefetched dynamic page behaved before Cache Components: click, wait for the server, then the whole page at once. The App Shell navigation mode is the model this guide teaches: the page's shell paints instantly from the prefetch cache, and only the genuinely per-request parts stream in. Run both, click a product, and watch the timeline.

Same server, same data, same 1.4-second round trip for the fresh stock count. The difference is entirely in what the browser already had when the click happened, and that's a thing you control. Let's build up to it properly, starting from what you already know.

Chapter 1

The shell you already know

A two-minute recap of Partial Prerendering, told in a way that sets up everything that follows.

When Cache Components is on, every route in your app is partially prerendered. At build time, Next.js renders each route's component tree and sorts the output into two piles:

On a document request, the shell is served immediately — typically straight from a CDN, no origin server involved — and the holes are rendered at request time, streaming into their fallbacks as they resolve. The user sees a real page instantly, and the personalized or fresh parts pop in a beat later.

A partially prerendered page. Green regions exist as HTML before any request happens. Hatched regions are Suspense holes: the shell contains their fallbacks, and their real content streams in at request time.

One sentence in that description deserves a second look: the shell is served on a document request. That's the contract. PPR's shell is an HTML artifact, addressed by URL, delivered when a browser asks for a page. Keep that in mind as we move to chapter 2, because it's exactly the assumption that's about to break.

Go deeper — what “prerender, then resume” actually produces

Internally, the build-time prerender emits two artifacts per route. The stream of bytes rendered before any dynamic access is the prelude — that's the static shell, both as HTML (for document requests) and as a serialized RSC payload (for client navigations; chapter 3 explains why that second form matters). Where the render hit dynamic data, React postpones: it records where it stopped in a blob of postponed state that Next.js stores alongside the shell.

At request time the server doesn't re-render the page from scratch. It parses the postponed state and resumes rendering from exactly the recorded stopping points, streaming the results into the shell's holes. If a route accesses dynamic data above the page's <body> with no <Suspense> above it, the prelude would be empty — the framework treats an empty shell as an error unless you've explicitly opted out, which is what the validation you'll meet in chapter 4 enforces.

Chapter 2

The click the shell can't help

Client-side navigations don't make document requests — and they re-render less of the tree than you might think.

After hydration, clicking a <Link> doesn't fetch a document. The router fetches an RSC payload — the serialized server-component tree for the destination — and grafts it into the running React app. Two consequences follow, and together they explain why PPR alone never made navigations instant.

Consequence 1: nobody serves you the HTML shell

The static shell you built in chapter 1 is sitting on a CDN, keyed by URL, waiting for a document request that isn't coming. A client navigation asks a different question — “give me the RSC data for /store/hats” — and in the old model, answering that question for a dynamic page meant rendering it on the server at click time. Until the response arrived, the router had nothing new to show. The old page just sat there.

Consequence 2: the transition re-renders less than a page load does

A client navigation keeps every layout that the current and destination routes share — that's the whole point of layouts. Only the tree below the shared layout re-renders. This sounds like a pure win, but it quietly disables a tool you've been relying on: a <Suspense> boundary in your root layout catches everything on a page load, but during a transition it sits above the re-render scope. It never fires. Whatever fallback story you wrote up there does not exist for navigations.

Document request → /store/hatseverything renders; any boundary can catch
app/layout.tsx — renders
store/layout.tsx — renders
store/[slug]/page.tsx — renders
Client nav → /store/shoes → /store/hatsshared layouts persist; only the page swaps
app/layout.tsx — kept, does not re-renderits <Suspense> can't fire here
store/layout.tsx — kept, does not re-render
store/[slug]/page.tsx — must come from somewhere, now
The re-render scope. On a client navigation, only the subtree below the deepest shared layout is replaced. Fallbacks defined above that point can't participate in the transition — the page needs its own answer to “what do I show immediately?”

What the old router did about it

The pre-Cache-Components router wasn't naive — it prefetched. But its prefetching had a shape that stopped working the moment pages got dynamic:

Destination Default <Link> prefetched… On click
Fully static page The entire page Instant (while the cache entry was fresh)
Dynamic page with loading.js Layouts down to the first loading.js boundary Loading skeleton, then wait for the server
Dynamic page, no loading.js Nothing Wait for the server, then everything at once

And the fine print made it worse in practice:

So the old model gave you a hard choice for dynamic pages: navigate into a loading.js skeleton at best, block at worst, or pay for full prefetches per link. What it never gave you was the thing PPR gave document requests — a cheap, always-available, mostly-rendered starting point.

Check yourself

Your root layout wraps {children} in <Suspense fallback={<AppSkeleton />}>. A user clicks from /dashboard to /settings. Why do they never see <AppSkeleton />?

Reveal answer

Both routes share the root layout, so the transition only re-renders below it. The root layout — including its <Suspense> boundary — is kept, not re-rendered, so the boundary never gets a chance to show its fallback. It only ever fires on document requests, where the whole tree renders. Any “what shows immediately?” answer for navigations has to live at or below the segments that actually change.

Chapter 3

A shell for navigations

Cache Components' answer: prefetch the prerendered parts, per segment, and compose them into the next page the instant the user clicks.

Remember the detail from chapter 1: the build-time prerender saves the shell in two forms. HTML, for document requests — and a serialized RSC payload, for exactly this moment. The shell was never only an HTML artifact. Cache Components puts the second form to work.

Here's the new model, end to end. While a page is idle, the router looks at the <Link>s in the viewport and quietly prefetches each destination's prerendered parts — the same green content from chapter 1, in RSC form, fetched per segment and stored in an in-memory cache. When the user clicks:

  1. The router composes the next page synchronously from what's already in the cache. Shared layouts are kept in place; the new segments mount with their prefetched content. This is the paint at ~0 ms.
  2. Any segment whose prefetched content was partial — it had holes — gets exactly one dynamic request, and the missing content streams into its <Suspense> fallbacks as it arrives. Fully prerendered segments don't contact the server at all.

The click stops being “ask the server, then render” and becomes “render, then let the server fill in the orange parts.” Which is precisely what PPR did for document requests — transplanted to the client.

Definition · from the Next.js glossary

App Shell — “A per-route prerender containing the parts of a page that don't depend on URL-specific data. Routes that read cookies() or headers() produce a session-personalized variant, cached per session on the client.” It's used as the prefetch payload during client navigations, the loading state during runtime prefetching, and the fallback for ISR with Cache Components.

Read that definition slowly, because each clause is load-bearing:

So the full picture has two layers of “ready before the click.” The router prefetches the destination's prerender for the concrete URL when it can; the App Shell is the per-route floor that's always there — including when a link's specific prefetch hasn't landed yet, when there are too many links to prefetch individually, or when the URL was never prefetchable in the first place.

Static shell (chapter 1) App Shell (this chapter)
Serves Document requests Client-side navigations
Delivered as HTML, from the CDN RSC payload, from the prefetch cache
Scope One per URL (concrete params when known) One per route, shared by every link to it
Holes filled by The resumed render, streamed into the document A dynamic request after the click, streamed into the live page
Personalization None — it's cached publicly Session variant when the route reads cookies or headers
Where the name comes from

“App shell” is an homage to the 2015-era PWA application shell pattern: cache the chrome of your app with a service worker so something meaningful paints before the network answers. The idea was right; the ergonomics were brutal — you hand-maintained the shell and its cache invalidation. Here the framework derives the shell from your component tree and your <Suspense>/'use cache' decisions, keeps it fresh, and personalizes it per session. Same instinct, no service worker.

Chapter 4

Build it: a store that navigates instantly

Four steps: turn it on, hit the guardrail, carve the seams with Suspense, then grow the shell with 'use cache'.

Theory over — let's build the store from the demo. The route is /store/[slug]: a product page showing details (name, price) that rarely change, and a live stock count that must be fresh on every request. The goal: clicking between products paints instantly.

Step 1 — Turn it on

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

One top-level flag. It enables the 'use cache' directive and its companions (cacheLife, cacheTag), turns on Partial Prerendering for every route, switches the router to per-segment prefetching, and — the part this guide cares about — generates App Shells. It also switches on validation, which you're about to meet.

Step 2 — Write the obvious thing, hit the guardrail

Here's the page the way you'd write it without thinking about shells at all:

app/store/[slug]/page.tsx blocks navigations
import { db } from '@/lib/db'

export default async function ProductPage(props: PageProps<'/store/[slug]'>) {
  const { slug } = await props.params
  const product = await db.products.findBySlug(slug)
  const stock = await db.inventory.findBySlug(slug)

  return (
    <main>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
      <p>{stock.count} in stock</p>
    </main>
  )
}

The dev overlay objects immediately:

This is the deal Cache Components strikes with you. Awaiting props.params at the top level makes the entire page depend on runtime data — nothing can be prerendered, there is no shell, and every navigation into this route would block on the server exactly like chapter 2. Rather than silently produce that experience, the framework makes you choose, per piece of data, one of three answers:

Validation runs in development on every page load and checks navigations too — including the “only re-renders below the shared layout” scenarios from chapter 2, which are nearly impossible to audit by hand across a growing app.

Step 3 — Carve the seams with Suspense

Answer one: push the awaits down, out of the page body and into children wrapped in <Suspense>. Pass params along as a promise — don't await it at the top — so the page component itself stays synchronous and prerenderable:

app/store/[slug]/page.tsx has a shell
import { Suspense } from 'react'
import { db } from '@/lib/db'

type Params = PageProps<'/store/[slug]'>['params']

export default function ProductPage(props: PageProps<'/store/[slug]'>) {
  return (
    <main>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductInfo params={props.params} />
      </Suspense>
      <Suspense fallback={<p>Checking stock…</p>}>
        <Stock params={props.params} />
      </Suspense>
    </main>
  )
}

async function ProductInfo({ params }: { params: Params }) {
  const { slug } = await params   // suspends here, inside the boundary
  const product = await db.products.findBySlug(slug)
  return (
    <>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
    </>
  )
}

async function Stock({ params }: { params: Params }) {
  const { slug } = await params
  const item = await db.inventory.findBySlug(slug)
  return <p>{item.count} in stock</p>
}

Validation passes. The route now has a shell: the <main> frame, the store layout above it, and the two fallbacks. Navigations into this route paint instantly — but they paint skeletons. Structurally sound, visually disappointing: the App Shell for this route is all fallback, because everything interesting depends on the slug. Time to grow the green.

Step 4 — Grow the shell with 'use cache'

Product details rarely change — nobody renames a boot mid-session. The stock count is genuinely per-request. That asymmetry is the whole design input: cache the first, keep streaming the second.

app/store/[slug]/page.tsx shell + cached content
async function ProductInfo({ params }: { params: Params }) {
  const { slug } = await params
  const product = await getProduct(slug)
  return (
    <>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
    </>
  )
}

async function getProduct(slug: string) {
  'use cache'
  return db.products.findBySlug(slug)
}

Note what's cached: not the component, the data function. getProduct is keyed by its argument, so each slug gets its own entry with a real lifetime. That lifetime is the ticket into prerenders — when the router prefetches /store/hats, the server can include the cached product info in the prerendered payload it hands back. The stock count has no lifetime, so it stays orange: behind its fallback in every prerender, streamed only after a real navigation.

Run the demo's second mode again with this page in mind — it's exactly this code. Click a product: name and price paint at 0 ms (they came down with the prefetch), “Checking stock…” shows for the one thing that honestly needs a server, and streams in when it answers.

Cold caches are honest

“Instant” assumes warm caches. The first time anyone lands on a given product, the server still has to compute getProduct('trail-boot') once — that navigation streams. Every prefetch and navigation after it, for any user, hits the cache. You control freshness with cacheLife() profiles ('use cache' defaults to a 15-minute revalidate) and on-demand invalidation with cacheTag() + updateTag().

Step 5 — See it, then pin it down

Two tools close the loop. In dev, open the Next.js DevTools Navigation Inspector and toggle Pause on navigations: the next click freezes the page at its initial state, labeled Client nav, showing precisely what painted before any server response — your shell, with real cached content in it. (Pair it with the React DevTools Suspense panel to toggle individual boundaries.)

Then lock the behavior in CI with the instant() helper from @next/playwright, which scopes assertions to what's visible before dynamic content streams:

e2e/store.test.ts
import { test, expect } from '@playwright/test'
import { instant } from '@next/playwright'

test('product info paints before the server answers', async ({ page }) => {
  await page.goto('/store/trail-boot')

  await instant(page, async () => {
    await page.click('a[href="/store/field-jacket"]')
    await expect(page.locator('h1')).toContainText('Field Jacket')
  })

  // after instant() exits, streamed content is allowed to land
  await expect(page.locator('text=in stock')).toBeVisible()
})
Go deeper — good shells vs. shells that merely pass validation

Validation checks that a shell exists, not that it's good. One giant <Suspense> around the whole page passes — and replaces everything with a single spinner on every navigation. The craft is in the seams: keep as much real content green as possible and put fallbacks only where data is genuinely in flight. A product page that keeps its name, image, and description visible while only the price and stock sit behind small fallbacks feels faster than a full-page skeleton at identical network timings.

The general lever is push down: the deeper your awaits sit in the tree, the more static siblings float up into the shell. Passing promises (params, or a shared data promise) down through props and awaiting them inside the boundary — as step 3 did — is the idiom that makes this mechanical. The same trick works in layouts: a layout that never awaits params itself stays entirely green even when its heading needs the slug.

Check yourself

Emboldened, you add 'use cache' to a helper that calls cookies() to read the user's currency preference. What happens, and why?

Reveal answer

It errors. A 'use cache' entry is shared across requests — potentially across users — so reading request data inside one is a contradiction (whose cookie would be in the cache?). You either read the cookie outside and pass the value in as an argument (the entry is then keyed by currency, shared by everyone with the same preference), or reach for 'use cache: private', which caches per-user in the browser. Both patterns are chapter 6's subject.

Chapter 5

One shell per route

What happens when there are two hundred links on the page — and how Partial Prefetching makes the App Shell the default unit of prefetching.

Chapter 4's story had one link and one destination. Real pages are feeds, tables, and sidebars — hundreds of <Link>s, many to the same route with different params. Prefetching each destination's full cached render, per URL, per visible link, adds up: it's chapter 2's “full prefetch per link” problem wearing a nicer outfit.

The App Shell's “per-route” property is the fix, and Partial Prefetching is the switch that makes it the default economics of <Link>:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

export default nextConfig

With it on, a plain <Link> stops prefetching per-URL page content and loads just the destination's App Shell — one payload, shared by every link to that route, no matter how many are on screen. You then choose which links deserve more:

Link Prefetches Use for
<Link href="/store/hats"> The /store/[slug] App Shell — once, shared across all product links The two hundred links in the feed
<Link href="/checkout" prefetch> App Shell plus the destination's cached content The handful of links users actually click next
<Link href="/legal" prefetch={false}> Nothing Rarely-used links you'd rather not spend on

Notice what prefetch={true} means now. Before Cache Components it meant “prefetch everything, dynamic data and all” — a full server render per link that even pulled cookie-dependent content. Under Partial Prefetching it means “also ship the cached parts.” Uncached reads stay behind their fallbacks until a real navigation, always. The expensive, easy-to-misuse hammer became a scoped, predictable one.

Adopting incrementally

Can't flip the global flag on a large app? Opt routes in one at a time with the route segment config export const prefetch = 'partial' in a page.tsx or layout.tsx — links pointing at that route load only its App Shell even without the global flag. In dev, the overlay flags each <Link prefetch={true}> whose destination hasn't adopted yet, so you can work through them one at a time. Once every route is covered, enable partialPrefetching: true and delete the per-route exports. (Partial Prefetching shipped with Next.js 16.3 and requires cacheComponents.)

It's worth pausing on how neatly the pieces now stack. Suspense seams and 'use cache' decide what's green (chapter 4). The App Shell packages the green that's URL-independent, per route. Partial Prefetching decides how much green ships before the click, per link. Three separate dials, each with a clear owner.

Check yourself

A category page renders 60 product links: 59 plain <Link>s and one <Link prefetch> to the current bestseller. Under Partial Prefetching, how many App Shell payloads come down, and what does the sixtieth link add?

Reveal answer

One. All 60 links target the /store/[slug] route, and the App Shell is per-route, so the 59 plain links share a single shell prefetch. The prefetch link additionally downloads the bestseller's cached content — getProduct('bestseller')'s entry — so navigating to it paints with real product info. Its stock count still streams: it's uncached, and no prefetch mode changes that.

Chapter 6

The personal parts

Getting session- and URL-dependent content into the shell — 'use cache: private', the extract-and-pass pattern, and runtime prefetching.

Everything green so far has been anonymous: the same product info for every user. But chapter 3's definition promised more — a session-personalized shell variant for routes that read cookies() or headers(). Your logged-in nav, the user's team, their currency. This chapter is about earning that.

One rule governs all of it, and it hasn't changed since chapter 4: a prerender only advances through content that has a cache lifetime. Prefetching happens before the click, so anything in a prefetched payload is by definition rendered ahead of time — and the framework will only render ahead of time what it's allowed to reuse. Uncached reads stay behind their fallbacks in every prerender, no matter which knobs you turn. What the knobs change is which request data is in scope when the prerender runs.

The obstacle, concretely

A dashboard layout with a personalized nav:

app/dashboard/layout.tsx
import { Suspense } from 'react'

export default function DashboardLayout({ children }: LayoutProps<'/dashboard'>) {
  return (
    <div>
      <Suspense fallback={<nav>…</nav>}>
        <UserNav />  // needs the session cookie
      </Suspense>
      <main>{children}</main>
    </div>
  )
}

The quiz at the end of chapter 4 told you why the naive fix fails: 'use cache' can't read cookies() — a shared cache entry can't contain one user's data. Two patterns resolve it, chosen by one question: is the result shared by many users, or tied to one?

Pattern 1 — Extract and pass (shared result)

app/dashboard/user-nav.tsx
import { cookies } from 'next/headers'

async function UserNav() {
  const team = (await cookies()).get('team')?.value  // read outside
  const topics = await getTopics(team)               // pass in
  return <nav>{topics.map(/* … */)}</nav>
}

async function getTopics(team: string | undefined) {
  'use cache'
  return db.topics.forTeam(team)
}

The cookie read stays outside the cached scope; only its value crosses in, as an argument. The cache entry is keyed by team, so everyone on the same team shares it — your database sees traffic proportional to the number of teams, not the number of users.

Pattern 2 — 'use cache: private' (per-user result)

app/dashboard/user-nav.tsx
async function getUser() {
  'use cache: private'
  const session = (await cookies()).get('session')?.value  // allowed here
  return db.users.findBySession(session)
}

The private variant may read request data directly, because its results are cached in the browser only — per user by construction, never on the server, and never part of the anonymous static shell. Reach for it when the value is inherently personal, or when the cookie read happens deep inside an auth helper you can't restructure around.

With either pattern in place, UserNav's content has a lifetime — so when the router builds this route's App Shell for this session, the personalized nav is in it. That's the “session-personalized variant” from the glossary definition, earned: the shell that prefetches for a logged-in user has their name already rendered, cached per session on the client rather than shared.

The last frontier: data that varies per link

One class of request data remains out of reach: values the App Shell can't know because they differ per destination URL — dynamic params not covered by generateStaticParams, and searchParams. A per-route shell can hold the session's nav, but it can't hold ?sort=price. For routes where that content is worth having before the click, opt in to runtime prefetching:

app/search/page.tsx experimental
export const prefetch = 'allow-runtime'

export default function SearchPage(props: PageProps<'/search'>) { /* … */ }

Now a <Link prefetch={true}> pointing at this route triggers a runtime prerender: the server renders the route again at prefetch time, with the destination URL fully resolved — params, search params, cookies, headers all in scope. The same advancement rule applies: it renders through static and cached content (including 'use cache' keyed by runtime-derived arguments, and 'use cache: private'), and stops at uncached reads, which keep their fallbacks. More of the page is real before the click; only the honestly-fresh parts stream after it.

The cost model is the old full-prefetch one — a server invocation per visible link — which is why it's per-route opt-in and worth being deliberate about. Skip it when the App Shell already paints everything useful, when the URL-dependent content must be fresh anyway (the prerender would stop at the same fallback), or when the route is linked often but visited rarely. And note its role in the stack: runtime prefetching is a per-link upgrade, racing the user's click. The App Shell is the floor underneath it — if the click wins the race, the shell still paints. Runtime prefetching is currently experimental; expect its surface to move.

Static shell per URL · chapter 1

ServesDocument requests, as HTML from the CDN ContainsStatic + cached content for a concrete URL; fallbacks elsewhere

App Shell per route · chapters 3–5

ServesClient navigations, as an RSC payload from the prefetch cache ContainsEverything URL-independent — including session data, when cached CostOne per route, shared by every link. Effectively free per link.

Runtime prerender per link · chapter 6, experimental

ServesA specific prefetched link, rendered with its URL resolved ContainsApp Shell content + cached content keyed by params/searchParams CostOne server invocation per visible opted-in link. Spend deliberately.
Three layers of “ready before the click.” Each layer narrows scope (URL → route → link) and admits more request data. Uncached reads join none of them — that rule is what makes all three safe to serve ahead of time.
Check yourself

A /reports?quarter=Q3 page shows the user's team name (from a cookie, via 'use cache' extract-and-pass), the Q3 revenue table (a 'use cache' query keyed by quarter), and a “generated just now” timestamp (uncached, per request). Which pieces can be rendered before the click, and what does each require?

Reveal answer

The team name can ride in the session-personalized App Shell — it's cached and doesn't depend on the URL. The revenue table depends on searchParams, so the App Shell can't hold it; it needs prefetch = 'allow-runtime' on the route (plus <Link prefetch>), after which the runtime prerender resolves quarter=Q3 and advances through the cached query. The timestamp is uncached: it streams after the navigation, behind its fallback, under every configuration — by design.

Chapter 7

Turning it on

Rolling Partial Prefetching into an app you already have — and re-reading the <Link prefetch> calls whose meaning just changed.

Every chapter so far has assumed the App Shell is what a <Link> prefetches. Turning that assumption on in an app you already have is its own small project — not because the switch is hard, but because it quietly changes what every link you've already written does.

The switch re-reads your links

Before Cache Components, a plain <Link> to a static page prefetched the whole page, and <Link prefetch={true}> went further still — it pulled dynamic, request-time data ahead of the click too. Partial Prefetching redraws both defaults:

So flipping the flag doesn't only add a capability. It re-reads every <Link prefetch={true}> in your codebase — the ones you wrote for the old meaning still compile, but they mean something narrower now.

Don't flip the whole app at once

partialPrefetching: true in next.config.ts turns this on for every route in one commit. On a five-page site, do that and move on. On an app with hundreds of links and years of prefetch={true} habits, changing every link's behavior simultaneously is a lot to hold in your head — and a lot to review in one diff.

Adopt one route at a time

You don't have to. A single route can opt in ahead of the global flag:

app/products/[slug]/page.tsx
export const prefetch = 'partial'

export default function Page() { /* … */ }

A <Link> pointing at a route with prefetch = 'partial' loads the App Shell only — the new behavior — while every other route keeps the old one. You convert the app route by route, each change small enough to reason about on its own.

Adopt per-route first, flip the flag last

While the global flag is off, next dev surfaces a link-prefetch-partial insight for every <Link prefetch={true}> still doing a legacy full prefetch of a route that hasn't adopted. That list is your worklist. Enabling partialPrefetching first marks every route adopted and silences the warning — which also throws away the signal. Convert routes until the list is empty for the ones you care about, then flip the flag and delete the per-route exports.

Re-read every prefetch={true}

The flag is the easy part. The real work is the audit: each prefetch={true} you already wrote now means “ship the shell and the cached content,” not “ship everything.” Re-read each one with the intuition this guide has been building — ask what the destination actually is, in the two colors you already think in:

The link points at… What Partial Prefetching does to it Your move
A fully static page — no 'use cache', no <Suspense> The App Shell is the whole page; the default already ships it Drop prefetch
A page with cached content worth having before the click The cached parts ride along with the shell Keep prefetch={true}
A page that reads request data the user needs on arrival prefetch={true} no longer prefetches that data Keep it; add prefetch = 'allow-runtime'
'partial' is not 'allow-runtime'

Two per-route prefetch values, two different jobs, easily confused. prefetch = 'partial' is the adoption opt-in — it switches one route to shell-only prefetching ahead of the global flag. prefetch = 'allow-runtime' is the enhancement from chapter 6 — it adds a URL-resolved runtime prerender on top. One turns the new default on; the other goes beyond it. Reaching for allow-runtime won't clear the adoption warning.

Once the flag is on and the audit is done, the whole system lines up with the model you've built: <Suspense> and 'use cache' decide what's in the shell, partialPrefetching makes the shell the default payload, prefetch={true} adds the cached content, and allow-runtime adds the per-link runtime prerender. There's nothing new to learn here — you're naming, in config, what you already understand.

Check yourself

You enable partialPrefetching: true. Your marketing home — fully static, no 'use cache', no <Suspense> — is linked with <Link href="/" prefetch={true}>. What changed, and what should you do?

Reveal answer

Nothing broke, and the prefetch={true} is now redundant. For a fully static route the App Shell is the entire page, so a default <Link> already prefetches all of it; prefetch={true} only adds cached content, which this page doesn't have. Drop the prop. Keep it only where the destination has cached content — or request data via allow-runtime — worth shipping before the click.

Appendix

Under the hood

Entirely optional — what the network tab and the router are actually doing. Read this when you want to debug prefetching, not to use it.

Everything above works without knowing any of this. But if you open the network tab on a Cache Components app, here's how to read what you see.

The prefetch protocol — one tree request, then per-segment requests

The client keeps two caches: a route tree cache (“which segments exist at this URL?” — structure only, no content) and a segment cache (the actual prerendered RSC per segment). Route structure only changes on deploy, so tree entries live long; segment entries carry their own lifetimes.

When a <Link> enters the viewport, the router first fetches the destination's route tree, then requests each segment it doesn't already have. In the network tab that looks like:

Request Distinguishing header Returns
GET /store/hats Next-Router-Segment-Prefetch: /_tree The route tree: segment names, slots, prefetch hints
GET /store/hats Next-Router-Segment-Prefetch: /_index The root segment's prerendered RSC
GET /store/hats Next-Router-Segment-Prefetch: /store/[slug]/… That segment's prerendered RSC (ancestors may be bundled in)

All of them also carry RSC: 1 and Next-Router-Prefetch: 1, and responses that come from the per-segment prerender are stamped x-nextjs-postponed: 2. Because these are static payloads with no per-user variation (the session-personalized cases go through a different, runtime request), they're highly CDN-cacheable — the same shell bytes serve every visitor.

Each cached segment records its RSC payload, whether it's partial (has holes that will need a dynamic request), when it goes stale, and which params it actually depended on. That last one powers layout deduplication: a store layout that never reads the slug is stored once, keyed independently of any product, and every /store/… prefetch and navigation reuses it instead of refetching.

The navigation — synchronous by design

The core of the navigation code is deliberately not an async function. From the router source:

“To allow for synchronous navigations whenever possible, this is not an async function. It returns a promise only if there's no matching prefetch in the cache. Otherwise it returns an immediate result and uses Suspense/RSC to stream in any missing data.”

On click, the router walks the cached route tree, reads each segment from the segment cache, and mounts what it finds — in the same synchronous pass as the click handler. For each segment it makes one binary decision: if the cached entry is complete, that's the segment, done, no server involved. If it's partial, the prefetched shell mounts immediately as the visible content while a deferred slot is wired up for the real thing; then a single dynamic request (one per navigation, not per segment) streams the missing pieces into all the deferred slots as they resolve.

Only when there's no usable prefetch at all — cold cache, expired entries, unknown route — does the router fall back to chapter 2's behavior: one blocking request carrying both static and dynamic data. Every improvement in this guide amounts to making that fallback rarer.

Staleness — who decides how long a prefetched segment lives

Every prefetch response carries a stale time, which the client turns into an absolute expiry for that segment. The server derives it from your cacheLife() profiles — the stale field of a profile is, precisely, the client-side prefetch lifetime for content cached under it. Statically prerendered content defaults to the staleTimes.static config (five minutes).

Two guardrails worth knowing: the client enforces a 30-second floor (a server sending absurdly short stale times would otherwise make prefetching pointless), and for the same reason, a cache profile whose stale is under 30 seconds excludes that content from static shells entirely — if it can't usefully be prefetched, it isn't prerendered into the shell. Expiry is lazy: entries aren't proactively purged; they're re-validated when read, and server actions or updateTag()/revalidateTag() bump version counters that invalidate en masse.

Recap

The whole picture

Seven sentences, one decision table, and where to go next.

  1. PPR's static shell serves document requests; after hydration, every click is a client-side navigation, which the shell — as HTML — never serves.
  2. Client navigations re-render only below the shared layout, so fallback plans that live above it (like a root-layout Suspense) don't exist during transitions.
  3. Cache Components prefetches the destination's prerendered parts as RSC payloads, per segment, and composes them synchronously at click time — paint first, stream the rest.
  4. The App Shell is the per-route floor of that system: everything URL-independent, shared by every link to the route, session-personalized when the route reads cookies or headers.
  5. You shape what's in shells with two tools: <Suspense> decides where content yields to a fallback; 'use cache' (and : private) decides what has a lifetime and may therefore be rendered before the click.
  6. Partial Prefetching makes the App Shell the default <Link> payload; prefetch={true} adds cached content; prefetch = 'allow-runtime' upgrades specific links with URL-resolved runtime prerenders.
  7. Uncached reads join no prerender, ever — they stream after navigation, behind the fallback you chose for them. That invariant is what makes everything else safe.

Which tool, when

You want… Reach for
Navigations to paint something instantly <Suspense> seams below the shared layout — validation guides you
Real content in the shell instead of skeletons 'use cache' on the data functions, cacheLife() for freshness
Session data (user's name, team) in the shell Extract-and-pass into 'use cache', or 'use cache: private'
Hundreds of links without hundreds of prefetches partialPrefetching: true — App Shell per route by default
To turn it on in an app you already have, incrementally export const prefetch = 'partial' per route, then flip partialPrefetching and delete the exports
The likely-next page fully warm, cached parts included <Link prefetch={true}> on that link
URL-dependent content (params, searchParams) pre-rendered per link export const prefetch = 'allow-runtime' (experimental)
To see what actually paints before the server answers DevTools Navigation Inspector; instant() from @next/playwright in CI
Out — this route isn't worth making instant export const instant = false, honestly and explicitly

Further reading

In the Next.js docs, in roughly the order this guide followed: Caching (getting started — the full Cache Components mental model), Ensuring instant navigations (guides — validation and DevTools workflow), Adopting Partial Prefetching (guides — migration and the <Link> audit), Runtime prefetching (guides — experimental), and the Glossary entries for App Shell, Static Shell, and Partial Prefetching. The API references for 'use cache', 'use cache: private', cacheLife, and the prefetch route segment config cover the corners a tutorial shouldn't.