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.
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.
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:
-
Content that can be known ahead of a request — static JSX, pure
computation, and anything you cached with
'use cache'— is rendered now, into the route's static shell. -
Content that can't — uncached
fetches,cookies(),headers(), dynamicparams,searchParams— is left as a hole, and the<Suspense>fallback around it is rendered into the shell instead.
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.
cached with
'use cache'
fallback: “Checking stock…”
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.
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.
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:
-
Route-level granularity. The cache was keyed per
route, not per segment. Prefetches for
/store/shoesand/store/hatscouldn't share the store layout they had in common — each prefetch carried the whole thing. -
Coarse, short lifetimes. Client cache entries
expired wholesale on a timer (
staleTimes: five minutes for static content, and since v15, zero for dynamic — dynamic pages were effectively never reusable). -
The escape hatch was expensive.
<Link prefetch={true}>forced a full prefetch — including the dynamic parts, cookies and all. One full server render per visible link, usually thrown away unclicked. Fine for one checkout button; ruinous for a feed of two hundred links. - Miss the window, pay full price. A prefetch never blocks a navigation. If it hadn't landed (or had expired) by click time, the router went straight to a blocking server request.
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.
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.
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:
- 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.
-
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.
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:
-
“Per-route” — not per URL. There is one App Shell
for
/store/[slug], not one for every slug. Two hundred product links on a page share a single prefetched shell. Rendering a<Link>becomes effectively free, no matter how many point at the same route. - “The parts that don't depend on URL-specific data” — the store nav, the page frame, and the fallbacks for anything slug-dependent. It's the destination's floor: what can be shown for any product before we know which product.
-
“A session-personalized variant” — a shell isn't
necessarily anonymous. If the route reads
cookies()orheaders(), the framework detects it and builds the shell for this user's session, cached per session in the browser rather than shared. Your logged-in nav can be part of the shell. (Chapter 6 covers how to make that actually work.)
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 |
“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.
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
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:
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:
-
Stream it — put it behind
<Suspense>; the fallback joins the shell, the content arrives after the click. -
Cache it — give it a lifetime with
'use cache'; the real content joins the shell. -
Block on it —
export const instant = false; an honest, explicit opt-out for routes where instant isn't worth the restructuring.
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:
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.
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.
“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:
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.
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.
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>:
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.
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.
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.
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:
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)
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)
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:
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 elsewhereApp 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.
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.
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:
-
a plain
<Link>now prefetches the App Shell, nothing URL-specific; -
prefetch={true}adds the route's cached content on top of the shell; -
prefetch={true}no longer pulls cookies, headers, or other request data ahead of time.
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:
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.
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.
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.
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.
The whole picture
Seven sentences, one decision table, and where to go next.
- PPR's static shell serves document requests; after hydration, every click is a client-side navigation, which the shell — as HTML — never serves.
- 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.
- 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.
- 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.
-
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. -
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. - 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.