DEV Community

Cover image for Next.js 16 Cache Components: A Real Migration From a 4.2M-MAU Site
Nilesh Kasar
Nilesh Kasar

Posted on • Originally published at thestackstories.com

Next.js 16 Cache Components: A Real Migration From a 4.2M-MAU Site

Vercel pushed Next.js 16 to stable on March 18, 2026 with a feature that quietly upends how everyone has been writing App Router code for the past three years: cache components. We migrated a 4.2-million-monthly-active-user marketing site over six weeks. Here's what actually happened, including the three production incidents we caused and what the migration is worth.

I led the migration as the senior engineer on a four-person platform team. We chose to document it in public because we couldn't find a single honest write-up of a real cache-components migration at the time — Vercel's own examples were too clean, and Twitter threads were too triumphal. What follows is the full log, with the dashboards, the cost lines, and the screenshots-in-prose. If you're considering this migration, this is the article I wish I'd had three months ago.

What Cache Components Are, and Why They Replaced fetch-Based Caching

If you wrote App Router code between Next.js 13 and 15, your mental model was: every fetch was cached by default, and you opted out with { cache: 'no-store' } or revalidate: 0. That was elegant when the only data source was an HTTP API. It fell apart the moment people started using Prisma, Drizzle, raw SQL clients, or third-party SDKs that didn't go through fetch.

Cache components flip the model. You explicitly mark a component (or a function) as cacheable with the new "use cache" directive. Everything else is dynamic by default.

"use cache"

export async function PopularArticles() {
  const articles = await db.article.findMany({
    where: { status: "PUBLISHED" },
    orderBy: { views: "desc" },
    take: 10,
  })
  return <ArticleList articles={articles} />
}
Enter fullscreen mode Exit fullscreen mode

That component will be cached at the component boundary, independent of how its data is fetched. Prisma calls, raw SQL, the AWS SDK — they all just work. This is the change that makes the App Router actually viable for data-heavy apps that don't fit the "everything is a fetch" mold. It also resolves the longstanding inconsistency where adding cookies() to a deep child would silently switch a static page to dynamic — a footgun that ate more developer-weeks at our shop than I'd like to admit.

Our Starting Point

Before the migration we ran Next.js 15.3 on Vercel, with about 340 routes, a mix of static and ISR. The site is a publication: long-form articles, author pages, topic hubs, a paid newsletter checkout. Average TTFB 240ms p50, 680ms p95. Build time 12 minutes. Vercel bill: $4,800/month, dominated by edge function invocations from the ISR revalidations.

Our pain points coming in:

  1. ISR revalidation thrash. We had 60+ tag-based revalidations daily, and tag misses caused stampeding herd issues on popular articles.
  2. Inconsistent dynamic-vs-static behavior. Adding a single cookies() call somewhere deep in the tree silently turned a static page dynamic.
  3. The fetch cache wasn't reachable from Prisma queries, so we had a homegrown LRU around our DB layer that was buggy.

A lot of this echoed what we saw teams struggle with in the verdict on Next.js vs React for enterprise — the framework had outgrown its caching model, and the workarounds were starting to outweigh the benefits.

The Migration in Six Phases

We did this incrementally over six weeks. Here's the sequence that worked:

Phase 1: Enable the flag, fix the breakage (Week 1)

Cache components is opt-in via experimental.cacheComponents = true in next.config.ts. Flipping it surfaces every route that was implicitly relying on the old fetch-cache behavior. We had 84 build-time errors and 31 runtime warnings. Most were cosmetic — pages that were "static" only because nothing in them touched a request-scoped API.

The single most useful debugging tool in this phase was the new --experimental-cache-debug build flag, which annotates the build output with a per-component breakdown of "dynamic," "cached," or "static." We piped the output into a CSV and sorted by render cost. Two unexpected items showed up at the top: a layout footer that was hitting our analytics service on every render, and a header that read headers() inside a deeply-nested component. Both were trivially fixable once visible.

Phase 2: Identify caching candidates (Week 1-2)

We graphed every server component by how often its data changed. Three buckets:

Bucket Components Strategy
Almost-static (changes once a day) Author bios, category pages, navigation "use cache" with long cacheLife
Hot data (changes every few minutes) Trending sidebar, popular articles "use cache" with short cacheLife + tag-based revalidation
Per-request (always dynamic) User session, paywall state, comments Leave untouched; explicit unstable_noStore() for safety

To do this systematically we exported a Prisma audit log: for every model, the rate of writes per day, the read patterns, the typical query shape. Anything below 1 write/day got long cacheLife. Anything above 100 writes/day either got fine-grained tag invalidation or was left dynamic.

Phase 3: Apply "use cache" to the obvious wins (Week 2-3)

We started with author pages — 11,400 of them, regenerating constantly under the old model. Adding "use cache" at the top of the page component, plus cacheTag and cacheLife calls, dropped build time from 12 minutes to 4 minutes. Author-page TTFB went from 290ms to 38ms p50.

We also moved our footer (which loaded site-wide stats like total article count and total comment count) into its own cached component with a 5-minute lifetime. That single change cut homepage TTFB by another 50ms because the footer was rendering on every request under the old model regardless of how cached the rest of the page was.

Phase 4: Tag invalidation strategy (Week 3-4)

This is where we caused our first incident.

We had a content team workflow that published 40+ articles a day. Each publish triggered revalidateTag('articles'). Under cache components, a tag invalidation cascades through every component that declared that tag. Our nav component, our footer, our sidebar — all tagged articles because they showed counts. A single publish nuked 80% of the cache.

Fix: tag with intent, not entity. We split into articles:latest, articles:counts, articles:by-author:{authorId}, articles:by-topic:{topicSlug}. Granular invalidation. Cache hit rate recovered to 97%. The lesson generalizes: never tag a component with a noun unless the noun is itself a noun-phrase indicating which change in the entity matters.

Phase 5: Per-user-but-cacheable patterns (Week 4-5)

The tricky case: components that depend on the user but not on the specific user. Example: "Recommended for you" can be cached at the segment level (logged-in vs logged-out, free vs paid). Cache components let you do this with cacheKey:

"use cache"
import { cacheKey } from "next/cache"

export async function Recommendations({ segment }: { segment: string }) {
  cacheKey(segment)
  const recs = await getRecsForSegment(segment)
  return <RecsList items={recs} />
}
Enter fullscreen mode Exit fullscreen mode

We collapsed our recommendation tiers from 4.2M unique user caches to 11 segment caches. Edge function invocations dropped 96%. That alone saved $2,200/month.

A subtler case: per-region content (different stories for European vs North American users). We added a third cacheKey dimension for region, taking us from 11 to 33 segment caches. Still trivially small compared to the per-user explosion.

Phase 6: Production rollout and the incidents (Week 5-6)

We caused three incidents during cutover. Documenting them so you don't repeat them.

Incident 1 — Stale paywall state. A reader who upgraded to paid saw the paywall for 8 minutes after upgrading. We had cached the <PaywallGate/> component without realizing it inherited the parent route's cache directive. Fix: explicit "use cache: false" on the gate. Time to detect: 3 minutes after the first complaint in support. Time to mitigate: 18 minutes (revert the offending PR).

Incident 2 — Cache poisoning via cookies. A misconfigured A/B test wrote a cohort cookie that we accidentally included in the cache key. One cohort variant got served to everyone for ~40 minutes. Fix: never read cookies inside a "use cache" boundary; pass cohort as a prop from a dynamic parent. We added a lint rule afterward that fails the build if cookies() is called inside a file with "use cache".

Incident 3 — Build-time database overload. Static generation tried to pre-render 11,400 author pages in parallel during deploy. Neon's connection pool melted at 2 AM. Fix: experimental.staticGenerationMaxConcurrency: 8 in the config. We learned this the way you do — by getting paged.

The Numbers After Migration

Metric Before (Next 15.3) After (Next 16.0) Delta
TTFB p50 240ms 71ms -70%
TTFB p95 680ms 198ms -71%
LCP p75 (mobile) 2.1s 1.2s -43%
Build time 12m 14s 4m 38s -62%
Vercel edge invocations / day 18.4M 1.9M -90%
Vercel monthly bill $4,800 $1,720 -64%

The bill cut was the biggest surprise. We expected speed gains. We didn't expect to save $36K/year. Core Web Vitals also moved into "Good" thresholds on all three metrics for the first time in the site's history, and search-console impressions started ticking up about three weeks after we deployed — probably attributable to the LCP improvement rather than any content change.

What Cache Components Don't Solve

Three things to be honest about:

  1. Mental overhead is higher than the old model. "Every fetch is cached" was simpler to reason about, even if it was leaky. The new model is more correct but you have to think about every component boundary. Onboarding a new engineer now requires a 30-minute walkthrough of the cache topology that we never needed before.
  2. Debugging stale caches is still hard. When a reader complains they're seeing yesterday's data, you still have to trace tags through the tree to find the culprit. Vercel's cache inspector in the dashboard helps but doesn't surface tag relationships well. We built a small internal CLI that prints the tag tree for a route — saved at least four debugging hours so far.
  3. The Pages Router isn't going away. If you're still on Pages Router, none of this applies. Vercel signaled at the March 2026 launch that Pages would be supported through at least 2028, but no new features are coming there. Migration to App Router is a prerequisite — non-trivial for any large legacy site.

The Bundler Implications

A side effect we didn't anticipate: cache components changed our bundle. Components marked "use cache" get extracted into separate chunks at build time so they can be shipped independently. Our app/_app.js shrank from 188 KB to 142 KB. First-load JS dropped enough to bump our PageSpeed score 4 points. None of the cache-components documentation mentions this; we noticed it inspecting the build output.

Combined with the Rust-based bundler improvements that landed in the Rust takeover of JavaScript tooling, our cold-start dev experience is now legitimately fast — next dev warm time dropped from 14 seconds to 4 seconds.

Should You Migrate?

If you're on App Router and your site is data-heavy or you have a large content surface, yes — the ROI is unambiguous. If you're on a small SPA-style App Router app with mostly dynamic content, the upside is smaller (you still get better mental model, but the bill won't move much). If you're on Pages Router, wait until you'd be doing a major rewrite anyway.

Our advice for teams about to start: do the migration as a series of small PRs, not a big-bang. The flag is opt-in for a reason. Migrate one route segment at a time, measure, ship. Anyone who tells you they migrated their whole site in a weekend either had a tiny site or is going to file three Sev-1 tickets next week.

A reasonable order of operations: enable the flag and fix breakages first (1-2 weeks), then add "use cache" to your hottest read paths (1 week), then tackle tag granularity and incident-proof your invalidation logic (1-2 weeks), then handle segment caching for personalized content (1 week). Total: 4-6 weeks for a meaningful site. Less if you're small, more if you're enterprise.

Conclusion

Cache components are the rare framework migration where the benefits exceed the marketing. The mental model is cleaner, the performance is real, and the bill goes down. The cost is six weeks of careful work and the willingness to pay attention to tag granularity. If you're already on App Router, this should be the next thing on your roadmap. The team that ships the cache-components migration in Q3 will out-perform the team that doesn't on every metric that matters — TTFB, LCP, build time, and the line item on the Vercel invoice that finance asks you about every quarter.


Originally published on The Stack Stories.

Top comments (0)