Skip to content
Nicolas Chiong· 5 min read

A Next.js Cache Components migration playbook

A practical playbook for moving App Router pages to Cache Components without turning every rendering decision into a rewrite.

I would not start a Next.js 16 migration by flipping cacheComponents: true across a production app and waiting for the build to teach me what I broke. The better path is smaller: pick one route, identify what can be cached, put request-time behavior behind Suspense, and only then make invalidation explicit.

Cache Components are useful because they remove a bad old habit. Instead of arguing whether a whole page is static or dynamic, I can cache the parts that deserve a stable shell and keep the personalized or fast-changing parts dynamic. That is a good model, but it is only good if the team treats caching as a contract, not a framework toggle.

The migration shape

Next.js 16 made Cache Components the new explicit caching model for the App Router. The release notes frame the change around "use cache", Partial Prerendering, and opt-in caching. Dynamic code runs at request time by default, which is a saner default for most teams than silently caching too much.

My migration shape is:

  1. Upgrade the framework first.
  2. Leave behavior unchanged.
  3. Enable Cache Components in a small branch.
  4. Move one route at a time.
  5. Add cache tags only where a mutation can name the thing it changed. Most production pages do not need clever cache topology. They need two or three named cached reads and clear escape hatches for user-specific data.

This is also why I would pair the migration with a build tooling sanity check. If the app has custom bundler assumptions, read my notes on Vite and Turbopack tradeoffs for React teams before blaming Cache Components for every changed build behavior.

Pick the first route by failure cost

The first route should not be the homepage, the checkout, or the most personalized dashboard. Pick a page with real traffic, low mutation pressure, and obvious sections: product detail, documentation, public profile, pricing, changelog, or a blog index backed by a CMS.

For that route, draw three boxes:

BoxWhat belongs thereFirst tool
Stable shellLayout, headings, static copy, predictable lists"use cache"
Fresh shared dataCMS rows, inventory, public countscacheLife, cacheTag
Request datasession, cookies, headers, private account stateSuspense boundary or client component

The important part is the third box. If a component reads cookies, headers, or per-user session data, do not hide that inside a cached subtree. Make it visible in the component tree. A future engineer should be able to scan the page and see which parts are static, which parts are cached, and which parts are genuinely request bound.

Convert reads before writes

I migrate reads first because invalidation is easier to reason about after the cached surface exists. A small data function is enough:

import { cacheLife, cacheTag } from 'next/cache'

export async function getPost(slug: string) {
  'use cache'
  cacheLife('hours')
  cacheTag(`post:${slug}`)

  return db.post.findUnique({ where: { slug } })
}

That is not a new data layer. It is one cached read with a lifetime and a tag. If the application already has a repository function, I would put the directive there only if every caller wants the same caching behavior. If not, keep a separate cached wrapper near the route. Shared abstractions are cheap to create and expensive to unwind.

After that, make the mutation name the same tag. Next.js 16 refined the distinction between revalidateTag and updateTag. I would use the stale-while-revalidate path for public content where a brief stale window is acceptable, and the read-after-write path for server actions where the user must immediately see their own update.

The mistake is tagging everything with posts. That works until one CMS edit invalidates half the site. Use coarse tags for cheap pages and precise tags for expensive ones. The tag name should look like the smallest product fact that changed.

Watch for hidden time and randomness

Vercel Academy calls out a practical failure mode: enabling Cache Components can expose Server Components that call Date.now(), new Date(), or similar runtime-only values during prerendering. I like that failure because it is honest. A timestamp in a cached shell was already ambiguous. The migration just makes the ambiguity visible.

My default fix is not to move the whole page back to request rendering. First ask what the timestamp means.

If it is decoration, remove it. If it is a content freshness label, cache it with the content. If it is truly live state, isolate it behind Suspense or a client component. That keeps the cached shell intact and documents the dynamic part in the markup.

The same rule applies to random IDs, A/B test assignments, geolocation, account state, and feature flags. If the value changes per request, it does not belong inside the stable shell unless you have made that scope explicit.

Keep the rollback boring

Before I migrate more routes, I want three boring checks in place:

  1. A build that fails on accidental request-only code inside cached routes.
  2. One mutation test or manual script that proves the right tag refreshes.
  3. A production log query for slow dynamic holes after deployment.

The third check matters because Partial Prerendering can make the first paint feel fixed while a slow nested component still annoys users. A cached shell is not permission to stop measuring the dynamic pieces.

I would also keep the first migration PR intentionally small. One route, one or two cached reads, one invalidation path, no new cache helper package. If the pattern survives review, then extract the minimum shared naming helper for tags. Not before.

React 19.2 also matters here because Cache Components sit beside newer React rendering primitives, not apart from them. I covered the useful parts in my React 19.2 field guide, especially where Suspense and transitions change the feel of navigation.

The decision I would make

For a production App Router app, I would migrate to Cache Components only route by route. I would start with public, content-heavy pages, keep private account state out of cached subtrees, and add tags where a writer, admin, or server action already knows the exact object it changed.

The future version of this work is not a giant caching architecture. It is a small catalog of page patterns the team trusts: cached content page, cached listing with tagged rows, dynamic account page, and mixed shell with one personalized island. Once those patterns are boring, the migration can move quickly without turning every page into a fresh design debate.

nextjscachingreactvercelfrontend

References

  1. nextjs.orgNext.js
  2. nextjs.orgNext.js Docs
  3. nextjs.orgNext.js Docs
  4. nextjs.orgNext.js Docs
  5. vercel.comVercel Academy

Related writing

← PreviousAn OpenTelemetry database semconv migration FAQ

Let's make something useful.

Start a conversation