You add a <Suspense> boundary around the slow part of a page, ship it, and client-side navigations into that route still show a blank screen for a beat before anything renders. The boundary is real. It works on the first load. It does nothing on a click.
This is written against Next.js 16.4.2 (verified against the framework's own GitHub release notes and documentation as of October 2026). The fix for the scenario above was shipped as a named feature in 16.3: Instant Navigations, built on two pieces — Partial Prefetching and the App Shell — and it is this series' first look at either.
What you'll learn
By the end of this article you'll be able to:
- Explain why a
<Suspense>boundary that works on a full page load can be silently skipped during a client-side navigation - Turn on Partial Prefetching and read what the App Shell actually contains
- Place your Suspense boundaries where Partial Prefetching can use them
- Use
ensureStaticand theinstantdev-time validation to catch a slow navigation before a user does - Adopt the feature incrementally instead of flipping one global switch on a large app
Who this is for: you're comfortable with the App Router, Server Components, and basic <Suspense> usage. If you haven't met "use cache", cacheLife(), or cacheTag() yet, this series' Cache Components Explained covers them — Partial Prefetching is built directly on top of Cache Components and requires it to be enabled.
Contents
- Why your Suspense boundary doesn't help on a click
- The mental model: three navigation stages
- Turning it on and reading the App Shell
- Moving the Suspense boundary to where it counts
- Per-link prefetching, ensureStatic, and validation
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
Why your Suspense boundary doesn't help on a click
Say you have a dashboard with a shared layout — a sidebar and header that are the same on every route — and a /reports/[id] page whose data takes a second or two to load:
// app/reports/[id]/page.tsx — the wrong way
export default async function ReportPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const report = await getReport(id) // slow — hits the database directly
return <ReportView report={report} />
}
You already know the textbook fix: wrap the slow part in <Suspense> so the rest of the page can render while it loads.
// app/reports/[id]/page.tsx — looks fixed
export default async function ReportPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return (
<Suspense fallback={<ReportSkeleton />}>
<Report id={id} />
</Suspense>
)
}
async function Report({ id }: { id: string }) {
const report = await getReport(id)
return <ReportView report={report} />
}
Load /reports/42 directly in the browser and this works exactly as expected: the skeleton appears instantly, the report streams in behind it. But click a <Link href="/reports/42"> from the dashboard's sidebar, and the navigation still pauses — no skeleton, just a delay, then the whole thing appears at once.
The Suspense boundary isn't broken. It's in the wrong place for what just happened. A direct page load renders the whole tree from the root layout down, so the boundary catches the slow Report component exactly as written. A client-side navigation only re-renders what's below the layout segments the destination shares with the current page — the sidebar and header aren't remounted, so nothing above them runs again either. If nothing has told the App Router what to show for the parts of /reports/42 that aren't yet known, there's no shell to paint immediately, and the Suspense fallback never gets its moment on screen.
That's the actual problem Instant Navigations exists to solve: giving the App Router a per-route, prerendered shell to show the instant you click — before the server has even started rendering the parts that depend on the URL.
The mental model: three navigation stages
The mental model: with Partial Prefetching enabled, a navigation isn't one request — it's three separate moments when server-rendered output becomes available to the browser, and each one is cacheable differently.
-
The App Shell. A per-route prerender containing everything in that route that doesn't depend on URL data — the parts of
/reports/[id]that look the same whetheridis42or7. This is computed once, like a static page, and served instantly. If a piece of content is cached ("use cache") with a stale time of at least five minutes, it's folded into the shell too; a route that reads cookies or headers gets its session-specific shell cached per session rather than per request. -
The per-link prefetch. When a
<Link>is hovered, focused, or enters the viewport, the browser can additionally fetch data that does depend on the URL — the actual report withid: 42— ahead of the click, so it's often already in hand by the time the user commits. - The navigation itself. Whatever wasn't covered by stages 1 and 2 — genuinely request-time work — streams in now, behind whatever Suspense boundary you've placed around it.
Before this feature existed, a <Link> prefetch was all-or-nothing: either the whole destination page was fetched ahead of time (expensive, and wrong for personalized content), or nothing was, and the user waited for everything on click. Partial Prefetching turns that into a staged handoff: shell now, URL-specific data soon, truly dynamic content last — each stage painting something real instead of a spinner.
This is also why the Suspense boundary from the previous section didn't fire: it was in the tree, but without a shell to render immediately around it, there was nothing for the browser to show before the boundary resolves. Fix the shell, and the fallback gets its chance again.
Turning it on and reading the App Shell
Partial Prefetching requires Cache Components (the previous episode in this series covers that feature on its own). Both flags live in next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
partialPrefetching: true,
}
export default nextConfig
Key concept: Both flags are opt-in —
create-next-app's defaultnext.config.tsships empty, with neither flag set. Whether your project is brand new or years old, this is the one-time step; the rest of the article assumes it's done.
Turning the flag on changes what a default <Link> does, with no other code changes required. Before, <Link href="/reports/42"> either prefetched the full page or nothing. After, it prefetches that route's App Shell by default — the sidebar, header, and anything else that doesn't depend on id. Setting prefetch={true} explicitly on a given <Link> goes further: it also resolves that link's URL data (params, searchParams) ahead of the click, at the cost of one extra server round-trip per link, which is why it's opt-in rather than the default for every link on a page.
// prefetch={true} resolves this link's own URL data ahead of the click —
// worth it for a handful of links, expensive for a long list
<Link href={`/reports/${id}`} prefetch={true}>{title}</Link>
Moving the Suspense boundary to where it counts
Go back to the dashboard from the first section. The fix isn't a new API — it's placing the existing <Suspense> boundary so the App Shell can be built around it, and caching the parts that qualify:
// app/reports/[id]/page.tsx — the Instant Navigations way
export default async function ReportPage({ params }: { params: Promise<{ id: string }> }) {
return (
<>
<ReportHeader /> {/* doesn't depend on id — joins the App Shell */}
<Suspense fallback={<ReportSkeleton />}>
<Report paramsPromise={params} />
</Suspense>
</>
)
}
async function Report({ paramsPromise }: { paramsPromise: Promise<{ id: string }> }) {
const { id } = await paramsPromise // reading params here, inside Suspense
const report = await getReport(id)
return <ReportView report={report} />
}
The change that matters is subtle but load-bearing: params is awaited inside the component wrapped by <Suspense>, not in the page component above it. Resolving params outside the boundary pulls URL-dependent work above the shell, so there's nothing left for the shell to serve statically. With it inside, ReportPage itself has no dependency on id at all — it's eligible for the App Shell — and the fallback has a real reason to appear: the shell paints immediately, then Report streams in once getReport resolves.
If getReport reads from a cacheable source, add "use cache" to shrink what's left to stream:
async function getReport(id: string) {
'use cache'
cacheLife('minutes') // keep this under the 5-minute floor and it won't join the shell
return db.reports.findById(id)
}
Per-link prefetching, ensureStatic, and validation
Two more pieces round out the picture, plus the path for adopting this on a codebase you didn't build from scratch.
ensureStatic, a route segment config export, asserts that a link's App Shell — and optionally its per-link prefetch too — is actually static, rather than silently degrading. Its values are 'auto' (the default, same as omitting it), 'shell', 'prefetch', 'navigation', and false: 'shell' requires the App Shell to be static, 'prefetch' requires the App Shell and per-link prefetch to be static, 'navigation' requires the entire server-rendered route to be static, and false opts a segment out of a stricter level a parent segment would otherwise impose:
// app/reports/[id]/page.tsx
export const ensureStatic = 'shell'
ensureStatic only works with Cache Components enabled — without cacheComponents: true in next.config.ts, the build fails rather than the export silently doing nothing.
Dev-time validation runs automatically once Partial Prefetching is enabled: Next.js flags a route whose navigation would block on uncached work, points at the missing Suspense boundary, and suggests the fix — exactly the mistake in this article's first example. The Navigation Inspector in Next.js DevTools freezes the page at its initial loading state so you can see precisely what the shell shows before anything streams, which is otherwise too fast to eyeball. If a route is still mid-migration and you want to silence validation for it deliberately, export instant = false from that segment; sibling routes keep being checked.
Adopting incrementally matters on anything larger than a toy app: rather than flipping partialPrefetching: true globally on day one, set export const prefetch = 'partial' on individual page/layout files first, fix what validation flags route by route, then turn on the global flag and run the official codemod to remove the now-redundant per-route exports:
npx @next/codemod@canary remove-partial-prefetch ./app
Edge cases and gotchas
-
"use cache: private"never joins the shell. It caches in the browser only, which is inherently per-user — there's nothing stable to prerender into a shared shell. - The five-minute floor is real. Cached content needs a stale time of at least five minutes to be folded into the App Shell; shorter-lived caches still work, they just stream instead of shipping with the shell.
- A route that reads cookies or headers still gets a shell — it includes the session-specific data rather than being excluded outright, which is easy to assume incorrectly if you've only read about the URL-data rule.
-
router.prefetch()doesn't expose<Link>'s prefetch-depth options. Its public API takes only anonInvalidatecallback, not a way to ask for the App Shell versus the fuller per-link prefetch. Reach for it to warm a route outside the viewport, not as a drop-in replacement for a specific<Link prefetch={...}>setting elsewhere on the same route. - This is additive to Cache Components, not a replacement. If a route isn't eligible for Cache Components (it has no cacheable or static content at all), Partial Prefetching has nothing to build a shell from, and the navigation behaves as it did before.
-
forbidden()andunauthorized()are still experimental in 16.4. Both remain gated behind the experimentalauthInterruptsflag and the API reference still marks them as experimental, unchanged since theirv15.1.0introduction. Don't build a 16.4 auth flow around them as if they were final APIs yet.
Best practices
Reach for Partial Prefetching when your app has shared chrome (nav, header, sidebar, filters) that's identical across many routes, and at least some of each route's content is cacheable or genuinely static — the bigger the gap between "shared chrome" and "per-URL data," the more a shell buys you.
Adopt it per-route first, not globally, on anything beyond a small app. The per-segment prefetch = 'partial' export plus the dev validation gives you a route-by-route migration with immediate feedback, instead of flipping one flag and triaging every broken navigation at once.
Avoid it when a route's output is almost entirely user- or request-specific — a live chat thread, a real-time dashboard — where there's little to nothing left for a shell to usefully prerender. The dev-time warning for "Partial Prefetching isn't configured" is a nudge, not a mandate; some routes are correctly dynamic end to end.
FAQ
Do I need Cache Components to use Partial Prefetching?
Yes. Partial Prefetching builds the App Shell out of the same static and cached content that Cache Components makes possible. Enable cacheComponents: true first; partialPrefetching: true has nothing to work with otherwise.
Why didn't my <Suspense> boundary show its fallback during navigation before I made this change?
Because client-side navigations only re-render the parts of the tree below the layout segments shared with the current page. Without a prerendered shell, there's nothing to display instantly above your boundary, so the fallback never gets a chance to appear before the real content arrives — which is exactly what this article's first example demonstrates.
Does enabling partialPrefetching change existing <Link prefetch={true}> usage?
It changes the default (prefetch unset or auto) to fetch the App Shell instead of the full page. An explicit prefetch={true} keeps doing a fuller, per-link prefetch that also resolves that link's URL data — it isn't replaced, just layered on top of the new default.
Is this the same thing as Partial Prerendering (PPR)?
No, though they're related and easy to conflate. Partial Prerendering describes a single route serving a static shell plus streamed dynamic content on one request. Partial Prefetching is about navigation — what a <Link> fetches ahead of a click, across multiple navigation stages.
Can I test that a navigation actually renders instantly, in CI?
Yes — @next/playwright ships an instant() helper built for this: it asserts on the UI that's available immediately after a navigation, so a regression that reintroduces a blocking fetch fails your test suite instead of a user's click.
Cheat sheet
| Task | Code / Config | Notes |
|---|---|---|
| Enable the feature |
cacheComponents: true, partialPrefetching: true in next.config.ts
|
Requires Cache Components; both are create-next-app defaults now |
Default <Link> behavior |
Prefetches the App Shell only | No code change needed once the flag is on |
| Full per-link prefetch | <Link prefetch={true}> |
Also resolves params/searchParams; costs one extra round-trip per link |
| Keep a page shell-eligible | Await params/searchParams inside the <Suspense>-wrapped component |
Resolving them in the page component itself disqualifies the shell |
| Enforce a static shell | export const ensureStatic = 'shell' |
No-op (with a dev warning) unless Partial Prefetching is on |
| Opt a route out of validation | export const instant = false |
Siblings are still validated |
| Adopt incrementally |
export const prefetch = 'partial' per route, then the global flag |
Clean up with npx @next/codemod@canary remove-partial-prefetch ./app
|
| Inspect the shell visually | Navigation Inspector in Next.js DevTools | Freezes the page at its initial loading state |
| Regression-test it |
instant() from @next/playwright
|
Asserts on the immediately-available UI after navigation |
// the whole pattern in one route
export const ensureStatic = 'shell'
export default function ReportPage({ params }: { params: Promise<{ id: string }> }) {
return (
<>
<ReportHeader />{/* no URL dependency — joins the App Shell */}
<Suspense fallback={<ReportSkeleton />}>
<Report paramsPromise={params} />{/* URL-dependent work stays inside the boundary */}
</Suspense>
</>
)
}
Key takeaways
- A client-side navigation only re-renders below the layout segments it shares with the current page — a Suspense boundary above that point does nothing on a click, only on a full page load.
- Partial Prefetching (Next.js 16.3+, requires Cache Components) splits a navigation into three stages: the App Shell, an optional per-link prefetch of URL data, and the navigation itself.
- Where you await
params/searchParamsdecides whether a page is shell-eligible — resolve them inside the Suspense boundary, not above it. -
ensureStatic, dev-time validation, the Navigation Inspector, and@next/playwright'sinstant()all exist to catch a non-instant navigation before a user does.
The dashboard from the first section didn't need a new library or a rewrite — it needed its existing <Suspense> boundary to sit somewhere the App Router could actually use it. That's usually true of Instant Navigations adoption generally: the hard part is rarely the config flag, it's finding every place params got awaited one component too early.
Have you turned on Partial Prefetching yet, and did the dev-time validation catch anything you didn't expect? I'd like to hear about it in the comments.
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
🧠 Test yourself
Think it clicked? Take the 8-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
📚 Read next
- Next.js proxy.ts Explained (with Cheat Sheet)
- Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16
- Next.js Cache Components Explained (with Cheat Sheet)
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)