{"id":577,"date":"2026-08-15T05:01:19","date_gmt":"2026-08-15T05:01:19","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/partial-prerendering-cache-components-the-default-that-flipped\/"},"modified":"2026-08-15T05:01:19","modified_gmt":"2026-08-15T05:01:19","slug":"partial-prerendering-cache-components-the-default-that-flipped","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/partial-prerendering-cache-components-the-default-that-flipped\/","title":{"rendered":"Partial Prerendering Isn&#8217;t a Flag Anymore in Next.js 16"},"content":{"rendered":"<p>Short version for the impatient: partial prerendering isn&rsquo;t an experimental flag any more, it&rsquo;s the default behaviour of the App Router once you turn on <code>cacheComponents<\/code>, and the mental model flipped from &ldquo;mark the dynamic bits&rdquo; to &ldquo;mark the cached bits.&rdquo; If you learned PPR in the Next 15 era like I did, you have to unlearn a chunk of it.<\/p>\n<p>I found this out the annoying way. Upgraded a client project, deleted <code>experimental.ppr<\/code> because the build told me to, and then spent an afternoon wondering why a dashboard that used to render instantly was now waiting on a database call before it sent a single byte.<\/p>\n<p>The flag didn&rsquo;t move. The default inverted.<\/p>\n<h2 id=\"what-actually-changed-between-next-15-and-next-16\">What actually changed between Next 15 and Next 16<\/h2>\n<p>In Next 15, partial prerendering was opt-in twice over. You set the flag globally and then marked individual routes:<\/p>\n<pre><code class=\"language-js\">\/\/ next.config.js  (Next 15)\nmodule.exports = {\n  experimental: { ppr: 'incremental' },\n}\n<\/code><\/pre>\n<pre><code class=\"language-jsx\">\/\/ app\/dashboard\/page.jsx  (Next 15)\nexport const experimental_ppr = true\n<\/code><\/pre>\n<p>Both of those are gone. In Next 16 you get one switch:<\/p>\n<pre><code class=\"language-ts\">\/\/ next.config.ts\nimport type { NextConfig } from 'next'\n\nconst nextConfig: NextConfig = {\n  cacheComponents: true,\n}\n\nexport default nextConfig\n<\/code><\/pre>\n<p>The <a href=\"https:\/\/nextjs.org\/docs\/app\/api-reference\/config\/next-config-js\/cacheComponents\" rel=\"nofollow noopener\" target=\"_blank\">cacheComponents docs<\/a> put it plainly: data fetching is dynamic by default, and you choose what to cache at the page, component, or function level. That single sentence is the whole migration. Under the old model, a page was static until you did something dynamic in it. Under the new one, a page is dynamic until you say <code>use cache<\/code>.<\/p>\n<p>That&rsquo;s why my dashboard got slower. It had never had an explicit cache directive anywhere, because it never needed one. The old defaults were doing the work invisibly.<\/p>\n<h2 id=\"the-static-shell-is-the-thing-worth-understanding\">The static shell is the thing worth understanding<\/h2>\n<p>Strip away the config and the idea is simple. Next prerenders an HTML shell it can serve immediately, then streams the dynamic parts in as they resolve. One route, two rendering modes, no route-level either\/or.<\/p>\n<p>The practical consequence: your Suspense boundaries stopped being a loading-spinner nicety and became the seam where the shell ends and the stream begins. Everything outside a boundary has to be resolvable at build time or the shell can&rsquo;t be produced.<\/p>\n<pre><code class=\"language-jsx\">\/\/ app\/dashboard\/page.jsx\nimport { Suspense } from 'react'\n\nasync function Nav() {\n  'use cache'\n  const links = await getNavLinks()\n  return &lt;nav&gt;{\/* ... *\/}&lt;\/nav&gt;\n}\n\nasync function Revenue() {\n  const rows = await db.revenue.forUser(await getUserId())\n  return &lt;Chart data={rows} \/&gt;\n}\n\nexport default function Page() {\n  return (\n    &lt;&gt;\n      &lt;Nav \/&gt;\n      &lt;Suspense fallback={&lt;ChartSkeleton \/&gt;}&gt;\n        &lt;Revenue \/&gt;\n      &lt;\/Suspense&gt;\n    &lt;\/&gt;\n  )\n}\n<\/code><\/pre>\n<p><code>Nav<\/code> goes in the shell. <code>Revenue<\/code> streams. I&rsquo;d been treating Suspense as a UX decision for years, and it turned out I&rsquo;d been making a caching decision the whole time without knowing it. I wrote a whole post on <a href=\"https:\/\/abrarqasim.com\/blog\/react-suspense-2026-the-loading-states-i-stopped-writing\" rel=\"noopener\">the loading states I stopped writing<\/a> back when I thought this was purely a rendering concern. It isn&rsquo;t.<\/p>\n<h2 id=\"use-cache-is-per-function-and-thats-the-actual-upgrade\">use cache is per-function, and that&rsquo;s the actual upgrade<\/h2>\n<p>Here&rsquo;s the part I like. <code>use cache<\/code> isn&rsquo;t a route setting. You can put it on a page, a component, or a plain async function:<\/p>\n<pre><code class=\"language-js\">async function getPricingTiers() {\n  'use cache'\n  const res = await fetch('https:\/\/api.example.com\/tiers')\n  return res.json()\n}\n<\/code><\/pre>\n<p>Anything that calls <code>getPricingTiers()<\/code> gets the cached result, wherever it lives. Combine it with <code>cacheLife<\/code> for duration and <code>cacheTag<\/code> for targeted invalidation, and you can cache one expensive query while leaving the rest of the page fully dynamic.<\/p>\n<p>The old model gave you route segment config, which meant the least cacheable thing on a page determined the behaviour of everything on it. I had routes where a single user-specific badge in the header dragged an otherwise static marketing page into full dynamic rendering. That specific frustration is what this release fixes.<\/p>\n<h2 id=\"the-migration-gotcha-nobody-warned-me-about\">The migration gotcha nobody warned me about<\/h2>\n<p>Cache Components requires the Node.js runtime. If you have <code>export const runtime = 'edge'<\/code> anywhere, it has to go. The <a href=\"https:\/\/nextjs.org\/docs\/app\/guides\/migrating-to-cache-components\" rel=\"nofollow noopener\" target=\"_blank\">migration guide<\/a> covers it, and I&rsquo;d read that guide, and I still missed it because the export was sitting in a middleware-adjacent route I hadn&rsquo;t touched in a year.<\/p>\n<p>The second one is stranger and I&rsquo;m still adjusting. With <code>cacheComponents<\/code> on, Next uses React&rsquo;s <a href=\"https:\/\/react.dev\/reference\/react\/Activity\" rel=\"nofollow noopener\" target=\"_blank\">Activity component<\/a> to keep recently visited routes mounted in a hidden state instead of unmounting them. Navigate away, navigate back, your form inputs and expanded sections are still there.<\/p>\n<p>Mostly that&rsquo;s lovely. It also means a dropdown that assumed it would be destroyed on navigation now isn&rsquo;t. I had a modal that closed itself on unmount. It stopped closing itself. Took me twenty minutes to work out that nothing was broken, the component just wasn&rsquo;t dying any more.<\/p>\n<p>Effects are cleaned up when a route is hidden and recreated when it comes back, so subscriptions behave. It&rsquo;s the render-once-on-mount assumptions that break.<\/p>\n<h2 id=\"should-you-turn-it-on\">Should you turn it on?<\/h2>\n<p>If you&rsquo;re on Next 16 and your app has pages that mix genuinely static chrome with per-user data, yes, and the win is real. Time to first byte on that dashboard went from waiting-on-Postgres to immediate once I&rsquo;d marked the shell properly.<\/p>\n<p>If your app is basically all dynamic, an admin panel behind auth where nothing is cacheable, you&rsquo;ll do the migration work and get very little back. I&rsquo;d skip it and revisit later.<\/p>\n<p>And if you&rsquo;re mid-migration from Pages Router, do that first. Doing both at once means every problem has two possible causes, which is a bad way to spend a week. I&rsquo;ve made that mistake on someone else&rsquo;s dime and I&rsquo;d rather not do it again.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Turn on <code>cacheComponents<\/code> in a branch, build, and read the errors. Next is fairly good at telling you which component broke the prerender and why, and the error list is a free audit of where your implicit caching assumptions were living.<\/p>\n<p>Then pick one route and get its shell right before you touch anything else. Trying to fix twelve routes simultaneously is how you end up reverting the whole thing.<\/p>\n<p>I do a fair amount of this kind of framework-upgrade archaeology on client projects, and it&rsquo;s usually the invisible defaults that cost the time, not the documented breaking changes. More of that sort of work is on my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">portfolio<\/a>, if you&rsquo;re curious what it looks like at scale.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Partial prerendering is now the App Router default via cacheComponents. Why my dashboard got slower after the upgrade, and the mental model that had to flip.<\/p>\n","protected":false},"author":2,"featured_media":576,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Partial prerendering is now the App Router default via cacheComponents. Why my dashboard got slower after the upgrade, and the mental model that had to flip.","rank_math_focus_keyword":"cache components nextjs","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[354],"tags":[353,61,634,41],"class_list":["post-577","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-react","tag-caching","tag-nextjs","tag-partial-prerendering","tag-react"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/577","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=577"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/577\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/576"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=577"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=577"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=577"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}