[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"verticals":3,"quiz-nextjs-weekly-instant-navigations-partial-prefetching":44,"search-suggestions":60,"quiz-article-nextjs-weekly-instant-navigations-partial-prefetching":108,"path-membership-nextjs-weekly-instant-navigations-partial-prefetching":526},[4,20,32],{"id":5,"slug":6,"name":7,"tagline":8,"description":9,"accentFrom":10,"accentTo":11,"icon":12,"defaultLocale":13,"locales":14,"features":16,"position":19},"019fe637-3d33-714b-b57f-23e163ffca0c","dev","Web Development","Read it. Run it. Prove it.","A post a day on modern web development — most with an editable playground and a quiz that explains every answer. Free, no account needed.","violet-500","cyan-400","◇","en",[13,15],"fa",{"courses":17,"paths":17,"articles":17,"exams":18,"flashcards":18,"packages":17,"community":17,"certificates":17,"teams":17,"commerce":17},true,false,0,{"id":21,"slug":22,"name":23,"tagline":24,"description":25,"accentFrom":26,"accentTo":10,"icon":27,"defaultLocale":13,"locales":28,"features":30,"position":31},"019fe637-3dc2-754c-8657-0f175bfee7c6","lang","Languages","Learn a language the way you learn a codebase.","Grammar explained the way good documentation explains an API — one idea at a time, each with a quiz.","amber-400","⌘",[13,15,29],"es",{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":17,"packages":18,"community":17,"certificates":17,"teams":18,"commerce":18},2,{"id":33,"slug":34,"name":35,"tagline":36,"description":37,"accentFrom":38,"accentTo":39,"icon":40,"defaultLocale":13,"locales":41,"features":42,"position":43},"7b3c16f2-931d-410e-802e-e1fa4edab7de","soft","Soft Skills","The half of the job nobody wrote documentation for.","Weekly, on the parts of working life that decide more than your code does — first weeks, meetings, interviews, promotions, and the people around you. Written from what actually happens, and recorded as a podcast you can listen to on the walk.","emerald-400","teal-300","◉",[13],{"courses":18,"paths":18,"articles":17,"exams":18,"flashcards":18,"packages":18,"community":17,"certificates":18,"teams":18,"commerce":18},3,{"id":45,"slug":46,"kind":47,"title":48,"description":49,"config":50,"verticalId":5,"vertical":55,"course":52,"_count":56,"access":57,"attempts":59,"questionCount":51},"01a149cb-6966-738a-bd4f-cca334ff8b60","nextjs-weekly-instant-navigations-partial-prefetching","PRACTICE_QUIZ","Next.js Instant Navigations & Partial Prefetching","Test yourself on the App Shell, the three navigation stages, and the Suspense-placement gotcha that Next.js 16.3's Instant Navigations exists to fix.",{"questionCount":51,"timeLimitSec":52,"shuffleQuestions":18,"shuffleOptions":17,"negativeMarking":19,"passScorePct":53,"maxAttempts":52,"revealAnswers":54,"allowFlagging":18,"allowBacktracking":17},8,null,70,"AFTER_SUBMIT",{"slug":6,"name":7},{"questions":51},{"allowed":17,"reason":58},"FREE",[],[61,65,69,73,77,81,85,89,93,97,101,104],{"slug":62,"name":63,"articles":64},"webdev","Webdev",148,{"slug":66,"name":67,"articles":68},"javascript","Javascript",121,{"slug":70,"name":71,"articles":72},"frontend","Frontend",87,{"slug":74,"name":75,"articles":76},"tutorial","Tutorial",57,{"slug":78,"name":79,"articles":80},"css","Css",44,{"slug":82,"name":83,"articles":84},"performance","Performance",24,{"slug":86,"name":87,"articles":88},"typescript","Typescript",20,{"slug":90,"name":91,"articles":92},"react","React",18,{"slug":94,"name":95,"articles":96},"browser","Browser",14,{"slug":98,"name":99,"articles":100},"programming","Programming",12,{"slug":102,"name":103,"articles":100},"node","Node",{"slug":105,"name":106,"articles":107},"accessibility","Accessibility",10,{"id":109,"slug":46,"title":110,"subtitle":52,"excerpt":111,"coverUrl":112,"locale":13,"readingMinutes":96,"publishedAt":113,"viewCount":114,"likeCount":19,"commentCount":19,"author":115,"vertical":120,"topic":121,"tags":124,"_count":129,"playground":131,"body":133,"bodyMd":480,"seo":481,"translationGroupId":483,"series":484,"podcastUrl":52,"verticalId":5,"thread":517,"assessments":519,"translations":522,"quiz":524},"01a149cb-68e6-773f-bbbb-fce803732119","Next.js 16.3 Instant Navigations: How Partial Prefetching Works","Next.js 16.3 shipped Instant Navigations: Partial Prefetching plus the App Shell. Learn the model, the Suspense gotcha, and a copy-paste cheat sheet.","\u002Fmedia\u002Fcovers\u002Fnextjs-weekly-instant-navigations-partial-prefetching.png","2026-10-20T06:10:59.936Z",63,{"id":116,"name":117,"username":118,"avatarUrl":52,"headline":119},"019fe637-3c25-7088-9034-39c9f15dc3c8","Parsa Jiravand","parsa","Frontend engineer · building bestpractic",{"slug":6,"name":7,"accentFrom":10,"accentTo":11},{"slug":122,"name":123},"nextjs","Nextjs",[125,126,127,128],{"slug":122,"name":123,"color":52},{"slug":62,"name":63,"color":52},{"slug":74,"name":75,"color":52},{"slug":66,"name":67,"color":52},{"assessments":130},1,{"slug":46,"title":132},"Next.js Instant Navigations — interactive playground",{"blocks":134,"version":130},[135,139,142,147,150,159,162,165,178,182,185,191,194,198,201,204,207,210,213,219,222,225,228,231,236,240,243,247,250,253,257,260,263,267,270,273,276,280,283,286,289,294,297,306,309,312,315,318,321,324,327,331,334,338,341,344,347,350,353,356,400,404,407,414,417,420,423,426,429,432,435,438,441,444,447,450,453,456,462,465,468,471,474],{"id":136,"html":137,"type":138},"b1","\u003Cp>You add a \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> 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.\u003C\u002Fp>","paragraph",{"id":140,"html":141,"type":138},"b2","\u003Cp>This is written against \u003Cstrong>Next.js 16.4.2\u003C\u002Fstrong> (verified against the framework&#39;s own GitHub release notes and documentation as of October 2026). The fix for the scenario above was shipped as a named feature in \u003Cstrong>16.3\u003C\u002Fstrong>: Instant Navigations, built on two pieces — Partial Prefetching and the App Shell — and it is this series&#39; first look at either.\u003C\u002Fp>",{"id":143,"html":144,"text":145,"type":146,"level":31},"b3","What you&#39;ll learn","What you'll learn","heading",{"id":148,"html":149,"type":138},"b4","\u003Cp>By the end of this article you&#39;ll be able to:\u003C\u002Fp>",{"id":151,"type":152,"items":153,"ordered":18},"b5","list",[154,155,156,157,158],"Explain why a \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> 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 \u003Ccode>ensureStatic\u003C\u002Fcode> and the \u003Ccode>instant\u003C\u002Fcode> dev-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",{"id":160,"html":161,"type":138},"b6","\u003Cp>\u003Cstrong>Who this is for:\u003C\u002Fstrong> you&#39;re comfortable with the App Router, Server Components, and basic \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> usage. If you haven&#39;t met \u003Ccode>&quot;use cache&quot;\u003C\u002Fcode>, \u003Ccode>cacheLife()\u003C\u002Fcode>, or \u003Ccode>cacheTag()\u003C\u002Fcode> yet, this series&#39; \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-cache-components-explained-with-cheat-sheet-55ob\">Cache Components Explained\u003C\u002Fa> covers them — Partial Prefetching is built directly on top of Cache Components and requires it to be enabled.\u003C\u002Fp>",{"id":163,"html":164,"text":164,"type":146,"level":31},"b7","Contents",{"id":166,"type":152,"items":167,"ordered":18},"b8",[168,169,170,171,172,173,174,175,176,177],"\u003Ca href=\"#why-your-suspense-boundary-doesnt-help-on-a-click\">Why your Suspense boundary doesn&#39;t help on a click\u003C\u002Fa>","\u003Ca href=\"#the-mental-model-three-navigation-stages\">The mental model: three navigation stages\u003C\u002Fa>","\u003Ca href=\"#turning-it-on-and-reading-the-app-shell\">Turning it on and reading the App Shell\u003C\u002Fa>","\u003Ca href=\"#moving-the-suspense-boundary-to-where-it-counts\">Moving the Suspense boundary to where it counts\u003C\u002Fa>","\u003Ca href=\"#per-link-prefetching-ensurestatic-and-validation\">Per-link prefetching, ensureStatic, and validation\u003C\u002Fa>","\u003Ca href=\"#edge-cases-and-gotchas\">Edge cases and gotchas\u003C\u002Fa>","\u003Ca href=\"#best-practices\">Best practices\u003C\u002Fa>","\u003Ca href=\"#faq\">FAQ\u003C\u002Fa>","\u003Ca href=\"#cheat-sheet\">Cheat sheet\u003C\u002Fa>","\u003Ca href=\"#key-takeaways\">Key takeaways\u003C\u002Fa>",{"id":179,"html":180,"text":181,"type":146,"level":31},"b9","Why your Suspense boundary doesn&#39;t help on a click","Why your Suspense boundary doesn't help on a click",{"id":183,"html":184,"type":138},"b10","\u003Cp>Say you have a dashboard with a shared layout — a sidebar and header that are the same on every route — and a \u003Ccode>\u002Freports\u002F[id]\u003C\u002Fcode> page whose data takes a second or two to load:\u003C\u002Fp>",{"id":186,"code":187,"type":188,"language":189,"highlight":190},"b11","\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx — the wrong way\nexport default async function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  const { id } = await params\n  const report = await getReport(id) \u002F\u002F slow — hits the database directly\n  return \u003CReportView report={report} \u002F>\n}","code","tsx",[],{"id":192,"html":193,"type":138},"b12","\u003Cp>You already know the textbook fix: wrap the slow part in \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> so the rest of the page can render while it loads.\u003C\u002Fp>",{"id":195,"code":196,"type":188,"language":189,"highlight":197},"b13","\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx — looks fixed\nexport default async function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  const { id } = await params\n  return (\n    \u003CSuspense fallback={\u003CReportSkeleton \u002F>}>\n      \u003CReport id={id} \u002F>\n    \u003C\u002FSuspense>\n  )\n}\n\nasync function Report({ id }: { id: string }) {\n  const report = await getReport(id)\n  return \u003CReportView report={report} \u002F>\n}",[],{"id":199,"html":200,"type":138},"b14","\u003Cp>Load \u003Ccode>\u002Freports\u002F42\u003C\u002Fcode> directly in the browser and this works exactly as expected: the skeleton appears instantly, the report streams in behind it. But click a \u003Ccode>&lt;Link href=&quot;\u002Freports\u002F42&quot;&gt;\u003C\u002Fcode> from the dashboard&#39;s sidebar, and the navigation still pauses — no skeleton, just a delay, then the whole thing appears at once.\u003C\u002Fp>",{"id":202,"html":203,"type":138},"b15","\u003Cp>The Suspense boundary isn&#39;t broken. It&#39;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 \u003Ccode>Report\u003C\u002Fcode> component exactly as written. A client-side navigation only re-renders what&#39;s \u003Cem>below\u003C\u002Fem> the layout segments the destination shares with the current page — the sidebar and header aren&#39;t remounted, so nothing above them runs again either. If nothing has told the App Router what to show for the parts of \u003Ccode>\u002Freports\u002F42\u003C\u002Fcode> that aren&#39;t yet known, there&#39;s no shell to paint immediately, and the Suspense fallback never gets its moment on screen.\u003C\u002Fp>",{"id":205,"html":206,"type":138},"b16","\u003Cp>That&#39;s the actual problem Instant Navigations exists to solve: giving the App Router a \u003Cem>per-route, prerendered shell\u003C\u002Fem> to show the instant you click — before the server has even started rendering the parts that depend on the URL.\u003C\u002Fp>",{"id":208,"html":209,"text":209,"type":146,"level":31},"b17","The mental model: three navigation stages",{"id":211,"html":212,"type":138},"b18","\u003Cp>\u003Cstrong>The mental model:\u003C\u002Fstrong> with Partial Prefetching enabled, a navigation isn&#39;t one request — it&#39;s three separate moments when server-rendered output becomes available to the browser, and each one is cacheable differently.\u003C\u002Fp>",{"id":214,"type":152,"items":215,"ordered":17},"b19",[216,217,218],"\u003Cstrong>The App Shell.\u003C\u002Fstrong> A per-route prerender containing everything in that route that doesn&#39;t depend on URL data — the parts of \u003Ccode>\u002Freports\u002F[id]\u003C\u002Fcode> that look the same whether \u003Ccode>id\u003C\u002Fcode> is \u003Ccode>42\u003C\u002Fcode> or \u003Ccode>7\u003C\u002Fcode>. This is computed once, like a static page, and served instantly. If a piece of content is cached (\u003Ccode>&quot;use cache&quot;\u003C\u002Fcode>) with a stale time of at least five minutes, it&#39;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.","\u003Cstrong>The per-link prefetch.\u003C\u002Fstrong> When a \u003Ccode>&lt;Link&gt;\u003C\u002Fcode> is hovered, focused, or enters the viewport, the browser can additionally fetch data that \u003Cem>does\u003C\u002Fem> depend on the URL — the actual report with \u003Ccode>id: 42\u003C\u002Fcode> — ahead of the click, so it&#39;s often already in hand by the time the user commits.","\u003Cstrong>The navigation itself.\u003C\u002Fstrong> Whatever wasn&#39;t covered by stages 1 and 2 — genuinely request-time work — streams in now, behind whatever Suspense boundary you&#39;ve placed around it.",{"id":220,"html":221,"type":138},"b20","\u003Cp>Before this feature existed, a \u003Ccode>&lt;Link&gt;\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":223,"html":224,"type":138},"b21","\u003Cp>This is also why the Suspense boundary from the previous section didn&#39;t fire: it was in the tree, but without a shell to render immediately around it, there was nothing for the browser to show \u003Cem>before\u003C\u002Fem> the boundary resolves. Fix the shell, and the fallback gets its chance again.\u003C\u002Fp>",{"id":226,"html":227,"text":227,"type":146,"level":31},"b22","Turning it on and reading the App Shell",{"id":229,"html":230,"type":138},"b23","\u003Cp>Partial Prefetching requires Cache Components (the \u003Ca href=\"https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-cache-components-explained-with-cheat-sheet-55ob\">previous episode\u003C\u002Fa> in this series covers that feature on its own). Both flags live in \u003Ccode>next.config.ts\u003C\u002Fcode>:\u003C\u002Fp>",{"id":232,"code":233,"type":188,"language":234,"highlight":235},"b24","\u002F\u002F next.config.ts\nimport type { NextConfig } from 'next'\n\nconst nextConfig: NextConfig = {\n  cacheComponents: true,\n  partialPrefetching: true,\n}\n\nexport default nextConfig","ts",[],{"id":237,"html":238,"type":239},"b25","\u003Cp>\u003Cstrong>Key concept:\u003C\u002Fstrong> Both flags are opt-in — \u003Ccode>create-next-app\u003C\u002Fcode>&#39;s default \u003Ccode>next.config.ts\u003C\u002Fcode> ships 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&#39;s done.\u003C\u002Fp>\n","quote",{"id":241,"html":242,"type":138},"b26","\u003Cp>Turning the flag on changes what a default \u003Ccode>&lt;Link&gt;\u003C\u002Fcode> does, with no other code changes required. Before, \u003Ccode>&lt;Link href=&quot;\u002Freports\u002F42&quot;&gt;\u003C\u002Fcode> either prefetched the full page or nothing. After, it prefetches that route&#39;s App Shell by default — the sidebar, header, and anything else that doesn&#39;t depend on \u003Ccode>id\u003C\u002Fcode>. Setting \u003Ccode>prefetch={true}\u003C\u002Fcode> explicitly on a given \u003Ccode>&lt;Link&gt;\u003C\u002Fcode> goes further: it also resolves that link&#39;s URL data (\u003Ccode>params\u003C\u002Fcode>, \u003Ccode>searchParams\u003C\u002Fcode>) ahead of the click, at the cost of one extra server round-trip per link, which is why it&#39;s opt-in rather than the default for every link on a page.\u003C\u002Fp>",{"id":244,"code":245,"type":188,"language":189,"highlight":246},"b27","\u002F\u002F prefetch={true} resolves this link's own URL data ahead of the click —\n\u002F\u002F worth it for a handful of links, expensive for a long list\n\u003CLink href={`\u002Freports\u002F${id}`} prefetch={true}>{title}\u003C\u002FLink>",[],{"id":248,"html":249,"text":249,"type":146,"level":31},"b28","Moving the Suspense boundary to where it counts",{"id":251,"html":252,"type":138},"b29","\u003Cp>Go back to the dashboard from the first section. The fix isn&#39;t a new API — it&#39;s placing the existing \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> boundary so the App Shell can be built around it, and caching the parts that qualify:\u003C\u002Fp>",{"id":254,"code":255,"type":188,"language":189,"highlight":256},"b30","\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx — the Instant Navigations way\nexport default async function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  return (\n    \u003C>\n      \u003CReportHeader \u002F> {\u002F* doesn't depend on id — joins the App Shell *\u002F}\n      \u003CSuspense fallback={\u003CReportSkeleton \u002F>}>\n        \u003CReport paramsPromise={params} \u002F>\n      \u003C\u002FSuspense>\n    \u003C\u002F>\n  )\n}\n\nasync function Report({ paramsPromise }: { paramsPromise: Promise\u003C{ id: string }> }) {\n  const { id } = await paramsPromise \u002F\u002F reading params here, inside Suspense\n  const report = await getReport(id)\n  return \u003CReportView report={report} \u002F>\n}",[],{"id":258,"html":259,"type":138},"b31","\u003Cp>The change that matters is subtle but load-bearing: \u003Ccode>params\u003C\u002Fcode> is awaited \u003Cem>inside\u003C\u002Fem> the component wrapped by \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode>, not in the page component above it. Resolving \u003Ccode>params\u003C\u002Fcode> outside the boundary pulls URL-dependent work above the shell, so there&#39;s nothing left for the shell to serve statically. With it inside, \u003Ccode>ReportPage\u003C\u002Fcode> itself has no dependency on \u003Ccode>id\u003C\u002Fcode> at all — it&#39;s eligible for the App Shell — and the fallback has a real reason to appear: the shell paints immediately, then \u003Ccode>Report\u003C\u002Fcode> streams in once \u003Ccode>getReport\u003C\u002Fcode> resolves.\u003C\u002Fp>",{"id":261,"html":262,"type":138},"b32","\u003Cp>If \u003Ccode>getReport\u003C\u002Fcode> reads from a cacheable source, add \u003Ccode>&quot;use cache&quot;\u003C\u002Fcode> to shrink what&#39;s left to stream:\u003C\u002Fp>",{"id":264,"code":265,"type":188,"language":189,"highlight":266},"b33","async function getReport(id: string) {\n  'use cache'\n  cacheLife('minutes') \u002F\u002F keep this under the 5-minute floor and it won't join the shell\n  return db.reports.findById(id)\n}",[],{"id":268,"html":269,"text":269,"type":146,"level":31},"b34","Per-link prefetching, ensureStatic, and validation",{"id":271,"html":272,"type":138},"b35","\u003Cp>Two more pieces round out the picture, plus the path for adopting this on a codebase you didn&#39;t build from scratch.\u003C\u002Fp>",{"id":274,"html":275,"type":138},"b36","\u003Cp>\u003Cstrong>\u003Ccode>ensureStatic\u003C\u002Fcode>\u003C\u002Fstrong>, a route segment config export, asserts that a link&#39;s App Shell — and optionally its per-link prefetch too — is actually static, rather than silently degrading. Its values are \u003Ccode>&#39;auto&#39;\u003C\u002Fcode> (the default, same as omitting it), \u003Ccode>&#39;shell&#39;\u003C\u002Fcode>, \u003Ccode>&#39;prefetch&#39;\u003C\u002Fcode>, \u003Ccode>&#39;navigation&#39;\u003C\u002Fcode>, and \u003Ccode>false\u003C\u002Fcode>: \u003Ccode>&#39;shell&#39;\u003C\u002Fcode> requires the App Shell to be static, \u003Ccode>&#39;prefetch&#39;\u003C\u002Fcode> requires the App Shell and per-link prefetch to be static, \u003Ccode>&#39;navigation&#39;\u003C\u002Fcode> requires the entire server-rendered route to be static, and \u003Ccode>false\u003C\u002Fcode> opts a segment out of a stricter level a parent segment would otherwise impose:\u003C\u002Fp>",{"id":277,"code":278,"type":188,"language":189,"highlight":279},"b37","\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx\nexport const ensureStatic = 'shell'",[],{"id":281,"html":282,"type":138},"b38","\u003Cp>\u003Ccode>ensureStatic\u003C\u002Fcode> only works with Cache Components enabled — without \u003Ccode>cacheComponents: true\u003C\u002Fcode> in \u003Ccode>next.config.ts\u003C\u002Fcode>, the build fails rather than the export silently doing nothing.\u003C\u002Fp>",{"id":284,"html":285,"type":138},"b39","\u003Cp>\u003Cstrong>Dev-time validation\u003C\u002Fstrong> 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&#39;s first example. The \u003Cstrong>Navigation Inspector\u003C\u002Fstrong> 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 \u003Ccode>instant = false\u003C\u002Fcode> from that segment; sibling routes keep being checked.\u003C\u002Fp>",{"id":287,"html":288,"type":138},"b40","\u003Cp>\u003Cstrong>Adopting incrementally\u003C\u002Fstrong> matters on anything larger than a toy app: rather than flipping \u003Ccode>partialPrefetching: true\u003C\u002Fcode> globally on day one, set \u003Ccode>export const prefetch = &#39;partial&#39;\u003C\u002Fcode> on individual page\u002Flayout 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:\u003C\u002Fp>",{"id":290,"code":291,"type":188,"language":292,"highlight":293},"b41","npx @next\u002Fcodemod@canary remove-partial-prefetch .\u002Fapp","bash",[],{"id":295,"html":296,"text":296,"type":146,"level":31},"b42","Edge cases and gotchas",{"id":298,"type":152,"items":299,"ordered":18},"b43",[300,301,302,303,304,305],"\u003Cstrong>\u003Ccode>&quot;use cache: private&quot;\u003C\u002Fcode> never joins the shell.\u003C\u002Fstrong> It caches in the browser only, which is inherently per-user — there&#39;s nothing stable to prerender into a shared shell.","\u003Cstrong>The five-minute floor is real.\u003C\u002Fstrong> 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.","\u003Cstrong>A route that reads cookies or headers still gets a shell\u003C\u002Fstrong> — it includes the session-specific data rather than being excluded outright, which is easy to assume incorrectly if you&#39;ve only read about the URL-data rule.","\u003Cstrong>\u003Ccode>router.prefetch()\u003C\u002Fcode> doesn&#39;t expose \u003Ccode>&lt;Link&gt;\u003C\u002Fcode>&#39;s prefetch-depth options.\u003C\u002Fstrong> Its public API takes only an \u003Ccode>onInvalidate\u003C\u002Fcode> callback, 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 \u003Ccode>&lt;Link prefetch={...}&gt;\u003C\u002Fcode> setting elsewhere on the same route.","\u003Cstrong>This is additive to Cache Components, not a replacement.\u003C\u002Fstrong> If a route isn&#39;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.","\u003Cstrong>\u003Ccode>forbidden()\u003C\u002Fcode> and \u003Ccode>unauthorized()\u003C\u002Fcode> are still experimental in 16.4.\u003C\u002Fstrong> Both remain gated behind the experimental \u003Ccode>authInterrupts\u003C\u002Fcode> flag and the API reference still marks them as experimental, unchanged since their \u003Ccode>v15.1.0\u003C\u002Fcode> introduction. Don&#39;t build a 16.4 auth flow around them as if they were final APIs yet.",{"id":307,"html":308,"text":308,"type":146,"level":31},"b44","Best practices",{"id":310,"html":311,"type":138},"b45","\u003Cp>\u003Cstrong>Reach for Partial Prefetching when\u003C\u002Fstrong> your app has shared chrome (nav, header, sidebar, filters) that&#39;s identical across many routes, and at least some of each route&#39;s content is cacheable or genuinely static — the bigger the gap between &quot;shared chrome&quot; and &quot;per-URL data,&quot; the more a shell buys you.\u003C\u002Fp>",{"id":313,"html":314,"type":138},"b46","\u003Cp>\u003Cstrong>Adopt it per-route first, not globally,\u003C\u002Fstrong> on anything beyond a small app. The per-segment \u003Ccode>prefetch = &#39;partial&#39;\u003C\u002Fcode> 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.\u003C\u002Fp>",{"id":316,"html":317,"type":138},"b47","\u003Cp>\u003Cstrong>Avoid it when\u003C\u002Fstrong> a route&#39;s output is almost entirely user- or request-specific — a live chat thread, a real-time dashboard — where there&#39;s little to nothing left for a shell to usefully prerender. The dev-time warning for &quot;Partial Prefetching isn&#39;t configured&quot; is a nudge, not a mandate; some routes are correctly dynamic end to end.\u003C\u002Fp>",{"id":319,"html":320,"text":320,"type":146,"level":31},"b48","FAQ",{"id":322,"html":323,"text":323,"type":146,"level":43},"b49","Do I need Cache Components to use Partial Prefetching?",{"id":325,"html":326,"type":138},"b50","\u003Cp>Yes. Partial Prefetching builds the App Shell out of the same static and cached content that Cache Components makes possible. Enable \u003Ccode>cacheComponents: true\u003C\u002Fcode> first; \u003Ccode>partialPrefetching: true\u003C\u002Fcode> has nothing to work with otherwise.\u003C\u002Fp>",{"id":328,"html":329,"text":330,"type":146,"level":43},"b51","Why didn&#39;t my \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> boundary show its fallback during navigation before I made this change?","Why didn't my \u003CSuspense> boundary show its fallback during navigation before I made this change?",{"id":332,"html":333,"type":138},"b52","\u003Cp>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&#39;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&#39;s first example demonstrates.\u003C\u002Fp>",{"id":335,"html":336,"text":337,"type":146,"level":43},"b53","Does enabling \u003Ccode>partialPrefetching\u003C\u002Fcode> change existing \u003Ccode>&lt;Link prefetch={true}&gt;\u003C\u002Fcode> usage?","Does enabling partialPrefetching change existing \u003CLink prefetch={true}> usage?",{"id":339,"html":340,"type":138},"b54","\u003Cp>It changes the \u003Cem>default\u003C\u002Fem> (\u003Ccode>prefetch\u003C\u002Fcode> unset or \u003Ccode>auto\u003C\u002Fcode>) to fetch the App Shell instead of the full page. An explicit \u003Ccode>prefetch={true}\u003C\u002Fcode> keeps doing a fuller, per-link prefetch that also resolves that link&#39;s URL data — it isn&#39;t replaced, just layered on top of the new default.\u003C\u002Fp>",{"id":342,"html":343,"text":343,"type":146,"level":43},"b55","Is this the same thing as Partial Prerendering (PPR)?",{"id":345,"html":346,"type":138},"b56","\u003Cp>No, though they&#39;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 \u003Cem>navigation\u003C\u002Fem> — what a \u003Ccode>&lt;Link&gt;\u003C\u002Fcode> fetches ahead of a click, across multiple navigation stages.\u003C\u002Fp>",{"id":348,"html":349,"text":349,"type":146,"level":43},"b57","Can I test that a navigation actually renders instantly, in CI?",{"id":351,"html":352,"type":138},"b58","\u003Cp>Yes — \u003Ccode>@next\u002Fplaywright\u003C\u002Fcode> ships an \u003Ccode>instant()\u003C\u002Fcode> helper built for this: it asserts on the UI that&#39;s available immediately after a navigation, so a regression that reintroduces a blocking fetch fails your test suite instead of a user&#39;s click.\u003C\u002Fp>",{"id":354,"html":355,"text":355,"type":146,"level":31},"b59","Cheat sheet",{"id":357,"head":358,"rows":362,"type":399},"b60",[359,360,361],"Task","Code \u002F Config","Notes",[363,367,371,375,379,383,387,391,395],[364,365,366],"Enable the feature","\u003Ccode>cacheComponents: true, partialPrefetching: true\u003C\u002Fcode> in \u003Ccode>next.config.ts\u003C\u002Fcode>","Requires Cache Components; both are \u003Ccode>create-next-app\u003C\u002Fcode> defaults now",[368,369,370],"Default \u003Ccode>&lt;Link&gt;\u003C\u002Fcode> behavior","Prefetches the App Shell only","No code change needed once the flag is on",[372,373,374],"Full per-link prefetch","\u003Ccode>&lt;Link prefetch={true}&gt;\u003C\u002Fcode>","Also resolves \u003Ccode>params\u003C\u002Fcode>\u002F\u003Ccode>searchParams\u003C\u002Fcode>; costs one extra round-trip per link",[376,377,378],"Keep a page shell-eligible","Await \u003Ccode>params\u003C\u002Fcode>\u002F\u003Ccode>searchParams\u003C\u002Fcode> \u003Cem>inside\u003C\u002Fem> the \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode>-wrapped component","Resolving them in the page component itself disqualifies the shell",[380,381,382],"Enforce a static shell","\u003Ccode>export const ensureStatic = &#39;shell&#39;\u003C\u002Fcode>","No-op (with a dev warning) unless Partial Prefetching is on",[384,385,386],"Opt a route out of validation","\u003Ccode>export const instant = false\u003C\u002Fcode>","Siblings are still validated",[388,389,390],"Adopt incrementally","\u003Ccode>export const prefetch = &#39;partial&#39;\u003C\u002Fcode> per route, then the global flag","Clean up with \u003Ccode>npx @next\u002Fcodemod@canary remove-partial-prefetch .\u002Fapp\u003C\u002Fcode>",[392,393,394],"Inspect the shell visually","Navigation Inspector in Next.js DevTools","Freezes the page at its initial loading state",[396,397,398],"Regression-test it","\u003Ccode>instant()\u003C\u002Fcode> from \u003Ccode>@next\u002Fplaywright\u003C\u002Fcode>","Asserts on the immediately-available UI after navigation","table",{"id":401,"code":402,"type":188,"language":189,"highlight":403},"b61","\u002F\u002F the whole pattern in one route\nexport const ensureStatic = 'shell'\n\nexport default function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  return (\n    \u003C>\n      \u003CReportHeader \u002F>{\u002F* no URL dependency — joins the App Shell *\u002F}\n      \u003CSuspense fallback={\u003CReportSkeleton \u002F>}>\n        \u003CReport paramsPromise={params} \u002F>{\u002F* URL-dependent work stays inside the boundary *\u002F}\n      \u003C\u002FSuspense>\n    \u003C\u002F>\n  )\n}",[],{"id":405,"html":406,"text":406,"type":146,"level":31},"b62","Key takeaways",{"id":408,"type":152,"items":409,"ordered":18},"b63",[410,411,412,413],"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 \u003Ccode>params\u003C\u002Fcode>\u002F\u003Ccode>searchParams\u003C\u002Fcode> decides whether a page is shell-eligible — resolve them inside the Suspense boundary, not above it.","\u003Ccode>ensureStatic\u003C\u002Fcode>, dev-time validation, the Navigation Inspector, and \u003Ccode>@next\u002Fplaywright\u003C\u002Fcode>&#39;s \u003Ccode>instant()\u003C\u002Fcode> all exist to catch a non-instant navigation before a user does.",{"id":415,"html":416,"type":138},"b64","\u003Cp>The dashboard from the first section didn&#39;t need a new library or a rewrite — it needed its existing \u003Ccode>&lt;Suspense&gt;\u003C\u002Fcode> boundary to sit somewhere the App Router could actually use it. That&#39;s usually true of Instant Navigations adoption generally: the hard part is rarely the config flag, it&#39;s finding every place \u003Ccode>params\u003C\u002Fcode> got awaited one component too early.\u003C\u002Fp>",{"id":418,"html":419,"type":138},"b65","\u003Cp>Have you turned on Partial Prefetching yet, and did the dev-time validation catch anything you didn&#39;t expect? I&#39;d like to hear about it in the comments.\u003C\u002Fp>",{"id":421,"html":422,"type":138},"b66","\u003C!-- playground:start -->",{"id":424,"html":425,"text":425,"type":146,"level":31},"b67","🎮 Try it yourself",{"id":427,"html":428,"type":138},"b68","\u003Cp>\u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-instant-navigations-partial-prefetching\u002Fplayground\">▶️ Open the interactive playground →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":430,"html":431,"type":138},"b69","\u003Cp>\u003Cem>Runs right in your browser — poke at it and watch the concept react live.\u003C\u002Fem>\u003C\u002Fp>",{"id":433,"html":434,"type":138},"b70","\u003C!-- playground:end -->",{"id":436,"html":437,"type":138},"b71","\u003C!-- quiz:start -->",{"id":439,"html":440,"text":440,"type":146,"level":31},"b72","🧠 Test yourself",{"id":442,"html":443,"type":138},"b73","\u003Cp>Think it clicked? \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-instant-navigations-partial-prefetching\u002Fquiz\">Take the 8-question quiz →\u003C\u002Fa>\u003C\u002Fstrong>\u003C\u002Fp>",{"id":445,"html":446,"type":138},"b74","\u003Cp>\u003Cem>Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.\u003C\u002Fem>\u003C\u002Fp>",{"id":448,"html":449,"type":138},"b75","\u003C!-- quiz:end -->",{"id":451,"html":452,"type":138},"b76","\u003C!-- related:start -->",{"id":454,"html":455,"text":455,"type":146,"level":31},"b77","📚 Read next",{"id":457,"type":152,"items":458,"ordered":18},"b78",[459,460,461],"\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-middleware-to-proxy-network-boundary\">Next.js proxy.ts Explained (with Cheat Sheet)\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-route-handlers-caching-streaming\">Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16\u003C\u002Fa>","\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-cache-components-explained\">Next.js Cache Components Explained (with Cheat Sheet)\u003C\u002Fa>",{"id":463,"html":464,"type":138},"b79","\u003C!-- related:end -->",{"id":466,"type":467},"b80","divider",{"id":469,"html":470,"type":138},"b81","\u003Cp>🚀 \u003Cstrong>Want more like this?\u003C\u002Fstrong> Every guide, playground, and quiz lives on \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">bestpractic.org\u003C\u002Fa>\u003C\u002Fstrong> — open it and \u003Cstrong>\u003Ca href=\"https:\u002F\u002Fbestpractic.org\u002F\">sign up free\u003C\u002Fa>\u003C\u002Fstrong> so the next one finds you.\u003C\u002Fp>",{"id":472,"html":473,"type":138},"b82","\u003Cp>\u003Cem>Thanks for reading! Let&#39;s stay connected:\u003C\u002Fem>\u003C\u002Fp>",{"id":475,"type":152,"items":476,"ordered":18},"b83",[477,478,479],"⭐ \u003Cstrong>GitHub\u003C\u002Fstrong> — follow me and star the projects: \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fparsajiravand\">github.com\u002Fparsajiravand\u003C\u002Fa>","💬 \u003Cstrong>Discord\u003C\u002Fstrong> — join the frontend best-practices community: \u003Ca href=\"https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ\">discord.gg\u002Fd9KRhuAwQ\u003C\u002Fa>","📸 \u003Cstrong>Instagram\u003C\u002Fstrong> — frontend best practices, daily: \u003Ca href=\"https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F\">@bestpractice___\u003C\u002Fa>","You add a `\u003CSuspense>` 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.\n\nThis 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.\n\n## What you'll learn\n\nBy the end of this article you'll be able to:\n\n- Explain why a `\u003CSuspense>` boundary that works on a full page load can be silently skipped during a client-side navigation\n- Turn on Partial Prefetching and read what the App Shell actually contains\n- Place your Suspense boundaries where Partial Prefetching can use them\n- Use `ensureStatic` and the `instant` dev-time validation to catch a slow navigation before a user does\n- Adopt the feature incrementally instead of flipping one global switch on a large app\n\n**Who this is for:** you're comfortable with the App Router, Server Components, and basic `\u003CSuspense>` usage. If you haven't met `\"use cache\"`, `cacheLife()`, or `cacheTag()` yet, this series' [Cache Components Explained](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-cache-components-explained-with-cheat-sheet-55ob) covers them — Partial Prefetching is built directly on top of Cache Components and requires it to be enabled.\n\n## Contents\n\n- [Why your Suspense boundary doesn't help on a click](#why-your-suspense-boundary-doesnt-help-on-a-click)\n- [The mental model: three navigation stages](#the-mental-model-three-navigation-stages)\n- [Turning it on and reading the App Shell](#turning-it-on-and-reading-the-app-shell)\n- [Moving the Suspense boundary to where it counts](#moving-the-suspense-boundary-to-where-it-counts)\n- [Per-link prefetching, ensureStatic, and validation](#per-link-prefetching-ensurestatic-and-validation)\n- [Edge cases and gotchas](#edge-cases-and-gotchas)\n- [Best practices](#best-practices)\n- [FAQ](#faq)\n- [Cheat sheet](#cheat-sheet)\n- [Key takeaways](#key-takeaways)\n\n## Why your Suspense boundary doesn't help on a click\n\nSay you have a dashboard with a shared layout — a sidebar and header that are the same on every route — and a `\u002Freports\u002F[id]` page whose data takes a second or two to load:\n\n```tsx\n\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx — the wrong way\nexport default async function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  const { id } = await params\n  const report = await getReport(id) \u002F\u002F slow — hits the database directly\n  return \u003CReportView report={report} \u002F>\n}\n```\n\nYou already know the textbook fix: wrap the slow part in `\u003CSuspense>` so the rest of the page can render while it loads.\n\n```tsx\n\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx — looks fixed\nexport default async function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  const { id } = await params\n  return (\n    \u003CSuspense fallback={\u003CReportSkeleton \u002F>}>\n      \u003CReport id={id} \u002F>\n    \u003C\u002FSuspense>\n  )\n}\n\nasync function Report({ id }: { id: string }) {\n  const report = await getReport(id)\n  return \u003CReportView report={report} \u002F>\n}\n```\n\nLoad `\u002Freports\u002F42` directly in the browser and this works exactly as expected: the skeleton appears instantly, the report streams in behind it. But click a `\u003CLink href=\"\u002Freports\u002F42\">` from the dashboard's sidebar, and the navigation still pauses — no skeleton, just a delay, then the whole thing appears at once.\n\nThe 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 `\u002Freports\u002F42` that aren't yet known, there's no shell to paint immediately, and the Suspense fallback never gets its moment on screen.\n\nThat'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.\n\n## The mental model: three navigation stages\n\n**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.\n\n1. **The App Shell.** A per-route prerender containing everything in that route that doesn't depend on URL data — the parts of `\u002Freports\u002F[id]` that look the same whether `id` is `42` or `7`. 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.\n2. **The per-link prefetch.** When a `\u003CLink>` is hovered, focused, or enters the viewport, the browser can additionally fetch data that *does* depend on the URL — the actual report with `id: 42` — ahead of the click, so it's often already in hand by the time the user commits.\n3. **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.\n\nBefore this feature existed, a `\u003CLink>` 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.\n\nThis 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.\n\n## Turning it on and reading the App Shell\n\nPartial Prefetching requires Cache Components (the [previous episode](https:\u002F\u002Fdev.to\u002Fparsajiravand\u002Fnextjs-cache-components-explained-with-cheat-sheet-55ob) in this series covers that feature on its own). Both flags live in `next.config.ts`:\n\n```ts\n\u002F\u002F next.config.ts\nimport type { NextConfig } from 'next'\n\nconst nextConfig: NextConfig = {\n  cacheComponents: true,\n  partialPrefetching: true,\n}\n\nexport default nextConfig\n```\n\n> **Key concept:** Both flags are opt-in — `create-next-app`'s default `next.config.ts` ships 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.\n\nTurning the flag on changes what a default `\u003CLink>` does, with no other code changes required. Before, `\u003CLink href=\"\u002Freports\u002F42\">` 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 `\u003CLink>` 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.\n\n```tsx\n\u002F\u002F prefetch={true} resolves this link's own URL data ahead of the click —\n\u002F\u002F worth it for a handful of links, expensive for a long list\n\u003CLink href={`\u002Freports\u002F${id}`} prefetch={true}>{title}\u003C\u002FLink>\n```\n\n## Moving the Suspense boundary to where it counts\n\nGo back to the dashboard from the first section. The fix isn't a new API — it's placing the existing `\u003CSuspense>` boundary so the App Shell can be built around it, and caching the parts that qualify:\n\n```tsx\n\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx — the Instant Navigations way\nexport default async function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  return (\n    \u003C>\n      \u003CReportHeader \u002F> {\u002F* doesn't depend on id — joins the App Shell *\u002F}\n      \u003CSuspense fallback={\u003CReportSkeleton \u002F>}>\n        \u003CReport paramsPromise={params} \u002F>\n      \u003C\u002FSuspense>\n    \u003C\u002F>\n  )\n}\n\nasync function Report({ paramsPromise }: { paramsPromise: Promise\u003C{ id: string }> }) {\n  const { id } = await paramsPromise \u002F\u002F reading params here, inside Suspense\n  const report = await getReport(id)\n  return \u003CReportView report={report} \u002F>\n}\n```\n\nThe change that matters is subtle but load-bearing: `params` is awaited *inside* the component wrapped by `\u003CSuspense>`, 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.\n\nIf `getReport` reads from a cacheable source, add `\"use cache\"` to shrink what's left to stream:\n\n```tsx\nasync function getReport(id: string) {\n  'use cache'\n  cacheLife('minutes') \u002F\u002F keep this under the 5-minute floor and it won't join the shell\n  return db.reports.findById(id)\n}\n```\n\n## Per-link prefetching, ensureStatic, and validation\n\nTwo more pieces round out the picture, plus the path for adopting this on a codebase you didn't build from scratch.\n\n**`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:\n\n```tsx\n\u002F\u002F app\u002Freports\u002F[id]\u002Fpage.tsx\nexport const ensureStatic = 'shell'\n```\n\n`ensureStatic` only works with Cache Components enabled — without `cacheComponents: true` in `next.config.ts`, the build fails rather than the export silently doing nothing.\n\n**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.\n\n**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\u002Flayout 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:\n\n```bash\nnpx @next\u002Fcodemod@canary remove-partial-prefetch .\u002Fapp\n```\n\n## Edge cases and gotchas\n\n- **`\"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.\n- **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.\n- **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.\n- **`router.prefetch()` doesn't expose `\u003CLink>`'s prefetch-depth options.** Its public API takes only an `onInvalidate` callback, 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 `\u003CLink prefetch={...}>` setting elsewhere on the same route.\n- **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.\n- **`forbidden()` and `unauthorized()` are still experimental in 16.4.** Both remain gated behind the experimental `authInterrupts` flag and the API reference still marks them as experimental, unchanged since their `v15.1.0` introduction. Don't build a 16.4 auth flow around them as if they were final APIs yet.\n\n## Best practices\n\n**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.\n\n**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.\n\n**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.\n\n## FAQ\n\n### Do I need Cache Components to use Partial Prefetching?\n\nYes. 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.\n\n### Why didn't my `\u003CSuspense>` boundary show its fallback during navigation before I made this change?\n\nBecause 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.\n\n### Does enabling `partialPrefetching` change existing `\u003CLink prefetch={true}>` usage?\n\nIt 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.\n\n### Is this the same thing as Partial Prerendering (PPR)?\n\nNo, 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 `\u003CLink>` fetches ahead of a click, across multiple navigation stages.\n\n### Can I test that a navigation actually renders instantly, in CI?\n\nYes — `@next\u002Fplaywright` 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.\n\n## Cheat sheet\n\n| Task | Code \u002F Config | Notes |\n| --- | --- | --- |\n| Enable the feature | `cacheComponents: true, partialPrefetching: true` in `next.config.ts` | Requires Cache Components; both are `create-next-app` defaults now |\n| Default `\u003CLink>` behavior | Prefetches the App Shell only | No code change needed once the flag is on |\n| Full per-link prefetch | `\u003CLink prefetch={true}>` | Also resolves `params`\u002F`searchParams`; costs one extra round-trip per link |\n| Keep a page shell-eligible | Await `params`\u002F`searchParams` *inside* the `\u003CSuspense>`-wrapped component | Resolving them in the page component itself disqualifies the shell |\n| Enforce a static shell | `export const ensureStatic = 'shell'` | No-op (with a dev warning) unless Partial Prefetching is on |\n| Opt a route out of validation | `export const instant = false` | Siblings are still validated |\n| Adopt incrementally | `export const prefetch = 'partial'` per route, then the global flag | Clean up with `npx @next\u002Fcodemod@canary remove-partial-prefetch .\u002Fapp` |\n| Inspect the shell visually | Navigation Inspector in Next.js DevTools | Freezes the page at its initial loading state |\n| Regression-test it | `instant()` from `@next\u002Fplaywright` | Asserts on the immediately-available UI after navigation |\n\n```tsx\n\u002F\u002F the whole pattern in one route\nexport const ensureStatic = 'shell'\n\nexport default function ReportPage({ params }: { params: Promise\u003C{ id: string }> }) {\n  return (\n    \u003C>\n      \u003CReportHeader \u002F>{\u002F* no URL dependency — joins the App Shell *\u002F}\n      \u003CSuspense fallback={\u003CReportSkeleton \u002F>}>\n        \u003CReport paramsPromise={params} \u002F>{\u002F* URL-dependent work stays inside the boundary *\u002F}\n      \u003C\u002FSuspense>\n    \u003C\u002F>\n  )\n}\n```\n\n## Key takeaways\n\n- 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.\n- 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.\n- Where you await `params`\u002F`searchParams` decides whether a page is shell-eligible — resolve them inside the Suspense boundary, not above it.\n- `ensureStatic`, dev-time validation, the Navigation Inspector, and `@next\u002Fplaywright`'s `instant()` all exist to catch a non-instant navigation before a user does.\n\nThe dashboard from the first section didn't need a new library or a rewrite — it needed its existing `\u003CSuspense>` 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.\n\nHave 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.\n\n\u003C!-- playground:start -->\n\n## 🎮 Try it yourself\n\n**[▶️ Open the interactive playground →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-instant-navigations-partial-prefetching\u002Fplayground)**\n\n_Runs right in your browser — poke at it and watch the concept react live._\n\n\u003C!-- playground:end -->\n\n\u003C!-- quiz:start -->\n\n## 🧠 Test yourself\n\nThink it clicked? **[Take the 8-question quiz →](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-instant-navigations-partial-prefetching\u002Fquiz)**\n\n_Instant feedback, a hint on every question, and an explanation for each answer — right or wrong._\n\n\u003C!-- quiz:end -->\n\n\u003C!-- related:start -->\n\n## 📚 Read next\n\n- [Next.js proxy.ts Explained (with Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-middleware-to-proxy-network-boundary)\n- [Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-route-handlers-caching-streaming)\n- [Next.js Cache Components Explained (with Cheat Sheet)](https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-cache-components-explained)\n\n\u003C!-- related:end -->\n\n---\n\n🚀 **Want more like this?** Every guide, playground, and quiz lives on **[bestpractic.org](https:\u002F\u002Fbestpractic.org\u002F)** — open it and **[sign up free](https:\u002F\u002Fbestpractic.org\u002F)** so the next one finds you.\n\n*Thanks for reading! Let's stay connected:*\n\n- ⭐ **GitHub** — follow me and star the projects: [github.com\u002Fparsajiravand](https:\u002F\u002Fgithub.com\u002Fparsajiravand)\n- 💬 **Discord** — join the frontend best-practices community: [discord.gg\u002Fd9KRhuAwQ](https:\u002F\u002Fdiscord.gg\u002Fd9KRhuAwQ)\n- 📸 **Instagram** — frontend best practices, daily: [@bestpractice___](https:\u002F\u002Fwww.instagram.com\u002Fbestpractice___\u002F)",{"title":110,"canonical":482,"description":111},"https:\u002F\u002Fbestpractic.org\u002Fblog\u002Fnextjs-weekly-instant-navigations-partial-prefetching","01a149cb-68e6-773f-bbbc-005f7eec6d49",{"name":485,"part":51,"total":51,"items":486},"Next.js Deep Dive",[487,491,496,500,504,508,512,516],{"slug":488,"title":489,"publishedAt":490,"readingMinutes":96},"nextjs-weekly-cache-components-explained","Next.js Cache Components Explained (with Cheat Sheet)","2026-09-01T11:24:15.087Z",{"slug":492,"title":493,"publishedAt":494,"readingMinutes":495},"nextjs-weekly-server-actions-mutations-security","Next.js Server Actions: Mutations & Security (Cheat Sheet)","2026-09-08T10:57:00.975Z",15,{"slug":497,"title":498,"publishedAt":499,"readingMinutes":96},"nextjs-weekly-parallel-intercepting-routes-modals","Next.js Parallel & Intercepting Routes: Modals Done Right","2026-09-15T17:50:47.218Z",{"slug":501,"title":502,"publishedAt":503,"readingMinutes":100},"nextjs-weekly-middleware-to-proxy-network-boundary","Next.js proxy.ts Explained (with Cheat Sheet)","2026-09-22T19:36:08.416Z",{"slug":505,"title":506,"publishedAt":507,"readingMinutes":495},"nextjs-weekly-route-handlers-caching-streaming","Next.js Route Handlers: GET Stopped Caching in 15 — How to Cache in 16","2026-09-29T09:16:08.176Z",{"slug":509,"title":510,"publishedAt":511,"readingMinutes":96},"nextjs-weekly-streaming-metadata-head","Next.js Streaming Metadata: Why Your `\u003Chead>` Looks Incomplete","2026-10-06T06:10:31.468Z",{"slug":513,"title":514,"publishedAt":515,"readingMinutes":495},"nextjs-weekly-image-priority-quality-changes","Next.js 16 Image: priority Deprecated, quality Now Allowlisted","2026-10-13T06:10:29.815Z",{"slug":46,"title":110,"publishedAt":113,"readingMinutes":96},{"id":518,"locked":18},"01a149cb-6923-746a-ab88-0e64937c6134",[520],{"id":45,"slug":46,"title":48,"_count":521},{"questions":51},[523],{"locale":13,"slug":46},{"id":45,"slug":46,"title":48,"_count":525,"questionCount":51},{"questions":51},[]]