What the Cache Components migration actually costs

Piotr Żarów

CEO at Dev and Deliver

2026-09-24

#Frontend

Time to read

12 mins

In this article

Introduction

Share this article

Introduction

Part 2 of 2. Part 1 covers the benchmark numbers and the pipeline change behind them.

Instant Navigations is the flashy part of Next.js 16.3, and it usually gets described as two config flags. We turned those flags on in two production codebases in September 2026, one static marketing site and one Medusa e-commerce storefront. It is a migration, and both its cost and its payoff scale with how much your app blocks on dynamic data.

What it cost and what it bought

The short version: on a site that already prerenders everything, the feature has nothing to do. On an app that reaches a database before it can show anything, it is worth real effort. So the thing to work out first is which of those two apps you have.

Cache Components is an opt-in Next.js 16.3 model in which data fetching is dynamic by default and you mark what to cache with the use cache directive. Next prerenders a static shell per route, serves it immediately, and streams the dynamic parts in behind it.

That behaviour is called Partial Prerendering and shows up in the build output as . Two config flags turn it on, cacheComponents and partialPrefetching, and a new Playwright helper, instant(), is meant to assert the result.

The two apps

The first is a bilingual B2B marketing site: around 20 routes, 50 source files, and 22 of its 23 build outputs already prerendered. No cookies(), no headers(), no searchParams, no server-side fetch anywhere.

The second is a Medusa v2 e-commerce storefront: 223 source files, a cart, a checkout, customer accounts and Stripe. It reads cookies() for cart id, JWT and cache id, and almost every route is ƒ (Dynamic), rendered per request.

They are opposite profiles, which is the point. Full sizing and versions are in part 1.

Starting with the easy one

On the marketing site we picked a real navigation, the homepage into a service detail page:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { expect, test } from '@playwright/test'
import { instant } from '@next/playwright'

test('navigating from the homepage to a service page is instant', async ({ page }) => {
  await page.goto('/')

  await instant(page, async () => {
    await page.locator('a[href="/services/processing"]').last().click()
    await expect(
      page.getByRole('heading', { level: 1, name: /Processing/ })
    ).toBeVisible()
  })
})

Turning the flags on broke the build immediately, and the cause was one line:

1
2
3
Error: Next.js encountered the unstable value `new Date()` while prerendering.
  > 137 |  <p>© {new Date().getFullYear()} Company name sp. z o.o. ...
    at src/components/layout/Footer.tsx:137:17

A copyright year in the footer. Because the footer renders on every page, one new Date() call blocked prerendering across the entire site. Fixed by hoisting it so it is evaluated once at module load instead of on every render:

1
2
3
4
+ const COPYRIGHT_YEAR = new Date().getFullYear()

- <p>© {new Date().getFullYear()} Company name sp. z o.o. ...
+ <p>© {COPYRIGHT_YEAR} Company name sp. z o.o. ...

A few minutes, one line, and the error named the file, the line and four possible fixes. Credit where it is due.

The test is supposed to fail before the change and pass after. Ours passed with the flags on, in 365 ms, so we reverted the flags and ran it again as a control.

It passed with the flags off too, in 266 ms.

The feature did nothing here, and that is not a bug. It exists to stop a page blocking on cookies(), headers() or an uncached fetch before it can show anything. This codebase has none of those. Every route is prerendered and prefetched, so these navigations were already instant.

But that test was too narrow, and the docs told us so. We only asked "does the UI appear without waiting on the network". Partial Prefetching has a second benefit:

Before Partial Prefetching, Next.js prefetched per visible link: a page with N links to N routes produced ~N route prefetches. With partialPrefetching: true, Next.js prefetches one reusable App Shell per route instead.

So we measured that too. We drove a real Chromium against a production build, scrolled each page so every link entered the viewport, and counted every App Router prefetch response and its size. The numbers were byte-identical across repeat runs.

So it is not a no-op after all. Consistently 2 fewer requests and between 9% and 21% less prefetch traffic per page.

The per-URL breakdown shows what actually changed, and it is not what we expected from the docs. With the flags off, the homepage was prefetching itself:

1
2
3
4
5
  42.4 KB  /?_rsc=KQDgQ5C3aSx8ZzkN     <- prefetch of the page we are already on
   9.0 KB  /?_rsc=PTC0wXctYfd-qKDV     <- and again
  41.6 KB  /en?_rsc=...
  20.8 KB  /for-suppliers?_rsc=...
  ... 9 more routes, 10-21 KB each

Turn the flags on and those two entries vanish while every other request is byte-identical. 42.4 + 9.0 = 51.4 KB, the entire measured delta.

The saving has nothing to do with the "one App Shell per route instead of N per link" dedup the docs describe, because URL-level dedup was already collapsing those on our site. What changed is that Next stops issuing a full route prefetch for the route the user is already on, which our header logo and home nav item were both triggering. The heavier your current page, the more you save.

That is a real, reproducible win. It is also bandwidth spent at idle rather than latency the user feels, and navigation was already instant either way. Set against a dynamic sitemap and changed navigation semantics, we still left the flags off, though it is a judgement call now rather than the clear no-op our first test suggested.

"Is it instant" was only half the question, and our original test plan asked only that half. If you run this experiment, count the requests too.

Worth knowing separately: prefetchInlining, which bundles small prefetch payloads into a single response, is on by default in 16.3 and needs no flags at all.

Two more things the docs mention that a benchmark will not show you. cacheComponents also changes navigation semantics, using React <Activity> to keep the previous route mounted and hidden so component state survives navigation. And enabling it flipped our sitemap.xml from static to dynamic, because it stamps new Date().toISOString() into every lastmod. Next did not error, it correctly decided the route cannot be prerendered.

We left both flags off.

The same feature on the storefront, where it had plenty to fix

The storefront is the case this feature was designed for: cookies() for cart, JWT and cache id, a checkout, and almost every route dynamic. Turning the same two flags on there produced four distinct blockers, in order.

Route segment config came first. Four route files set export const revalidate = 86400, which cacheComponents rejects outright. Checking before deleting was worth it: three of those four routes were already ƒ (Dynamic) anyway, because the data layer reads cookies on every call. The setting had been inert for a long time. Only the collections route was genuinely affected.

Then empty generateStaticParams. Cache Components requires every generateStaticParams to return at least one result. One route returned none, because that dataset has no collections. A data shape that was previously fine now fails the build.

The checkout page is the canonical case, and it worked. It awaited the cart, which reads cookies, at the top of the page, blocking everything behind it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
-export default async function Checkout() {
-  const cart = await retrieveCart()
-  if (!cart) return notFound()
-  const customer = await retrieveCustomer()
-  return (
-    <div className="grid ...">
-      <CheckoutForm cart={cart} customer={customer} />
-      <CheckoutSummary cart={cart} />
-    </div>
-  )
-}
+export default function Checkout() {
+  return (
+    <div className="grid ...">
+      <Suspense fallback={<CheckoutSkeleton />}>
+        <CheckoutContent />
+      </Suspense>
+    </div>
+  )
+}
+
+async function CheckoutContent() {
+  const cart = await retrieveCart()
+  if (!cart) return notFound()
+  const customer = await retrieveCustomer()
+  return (
+    <>
+      <CheckoutForm cart={cart} customer={customer} />
+      <CheckoutSummary cart={cart} />
+    </>
+  )
+}

The shell renders immediately and the cart streams in behind it. That was one edit on the hardest page in the app.

Then everything else, because fixing the shared layout revealed the real scale. The (main) layout awaited params at the top level, which blocked prerendering for every route beneath it and masked the rest. Unblock it and the build reports 128 errors across all 20 routes: every page awaiting params or cookies, both nested layouts, and two shared client components reading URL data, one of which is used in 20 files.

We took it all the way. What it needed:

  • generateStaticParams for the country segment, so params are known at build time rather than being dynamic data
  • a <Suspense> boundary in the shared layout around the chrome that needs the country code
  • the canonical refactor on each page that awaited cookies or params
  • export const instant = false on two pages that perform a mutation while rendering and therefore can never be prerendered

next/root-params, new in 16.3 and seemingly built for exactly this, turned out not to apply: root params must sit above the root layout, and here the country segment sits below app/layout.tsx. Worth checking your own structure before counting on it.

What the migration was actually worth

A 66 to 70% drop in time to first byte on a cold data cache, because the shell is now sent before the commerce backend is reached at all.

Two notes on measuring it, because they cost us time. The instant() helper passed both before and after on our store-to-product navigation: Next already prefetches that route either way, so the assertion is satisfied regardless. And warm-cache TTFB does not discriminate at all, since the data is already cached. Cold cache is the only place the difference shows up. If you benchmark this, clear .next/cache/fetch-cache between runs or you will conclude the feature does nothing.

So the two projects bracket the feature nicely. On one, the only blocker was a copyright year, the fix was one line, and the feature then had nothing to do. On the other it took a dozen files, two layouts, a seeded database row and a pass over every page, and it bought a 70% cut in cold TTFB and 120 prerendered pages instead of 43.

Neither of those is a flag flip. What decides the cost is whether your app blocks on dynamic data before it can show anything. Ours did, on every route, and the payoff was proportional.

What to do with this

  1. Find out which app you have. Grep for cookies(), headers(), searchParams and uncached fetch in your page components. No hits probably means no payoff. Plenty of hits, especially in a shared layout, means real upside and a real migration.
  2. Fix the shared layout first. One await params in a layout blocks prerendering for every route under it and hides every other error behind it. We thought we had two broken pages. We had twenty.
  3. Measure on a cold cache. Neither the instant() helper nor warm-cache time to first byte told us anything on the app where the feature clearly worked. Clear .next/cache/fetch-cache and measure again, or you will conclude it does nothing.
  4. Budget it separately from the upgrade. The version bump needs no code. This does. They are two different tickets, and part 1 is about the one you can do this week.

Questions we were asked while writing this

Is the Cache Components migration worth it?

On an app that blocks on dynamic data, yes. On our storefront it moved nearly every route from ƒ (Dynamic) to ◐ (Partial Prerender), took prerendered pages from 43 to 120, and cut cold-cache time to first byte by 66% to 70%. On a fully static site it had nothing to do. Grep for cookies(), headers(), searchParams and uncached fetch in your page components to find out which you are.

How do you measure whether Cache Components actually helped?

Not with the instant() helper alone, and not with a warm cache. Both passed identically before and after the migration on our storefront. Clear .next/cache/fetch-cache and measure time to first byte on a cold data cache, which is the only place the difference showed up.

About the author

Piotr Zarow is CEO at Dev and Deliver, a Krakow-based software house working with senior React, Next.js, Node and NestJS engineers. He carried out the migration described here on a production Medusa storefront in September 2026, took it to a green build, measured it, and then reverted it on a branch.

Planning a Next.js upgrade, or trying to work out whether a release is worth the migration effort on your codebase? We are a Krakow-based software house working with senior React, Next.js, Node and NestJS engineers, and this kind of measure-first assessment is how we approach it. Get in touch and tell us what you are building.

Piotr Żarów

CEO at Dev and Deliver

Share this post

Related posts

Want to light up your ideas with us?

Kickstart your new project with us in just 1 step!

Prefer to call or write a traditional e-mail?