With Next.js 16.4 (October 2026), the Next.js team started recommending Cache Components for every app. New projects from create-next-app have it switched on, and the release notes say it will become the default in Next.js 17.

If you have an existing App Router app, that gives you a choice: migrate now at your own pace, or do it under pressure during a major upgrade. This post walks through the migration by hand, in the order that keeps your app building the whole time.

Short version: enable cacheComponents, run the codemod that opts every route out of validation, fix the few things that can't be deferred, then convert one route at a time. Nothing is cached any more unless you write use cache. Patch to 16.4.5 first.

Last checked: 15 November 2026, against the Next.js 16.4 documentation.

What actually changes

In the older App Router model, caching was mostly implicit and set per route: export const dynamic, export const revalidate, fetchCache, and cache options on fetch.

With Cache Components the rule flips:

  • Nothing is cached unless you mark it with the 'use cache' directive.
  • You mark the function or component that should be cached, instead of setting a route-wide config.
  • Anything that needs the request (cookies, headers, search params) goes inside a <Suspense> boundary, so the rest of the page can be prerendered as a static shell and the request-specific part streams in.

The Next.js team describes use cache as a component-level version of the Cache-Control header.

Before you start

  1. Be on Next.js 16. Cache Components needs it. Coming from 15 or older, do the version upgrade first.
  2. Install 16.4.5 or later. The November 2026 security release fixed several cache-disclosure bugs in Cache Components. Details in my write-up of that release.
  3. Decide who drives. There's an official agent skill that does this migration feature by feature (npx skills add vercel/next.js --skill next-cache-components-adoption), and next upgrade --agent gives a coding agent version-specific guidance. Even if you use them, read on: you're the one reviewing the pull requests.

The migration, step by step

Step 1: turn it on

// next.config.ts import type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, }; export default nextConfig; 

The 16.4 release notes treat Partial Prefetching as part of the model, so enable both. If you previously used experimental.dynamicIO or experimental.useCache, cacheComponents replaces them.

Your build will now fail on any route segment that still exports dynamic, revalidate or fetchCache. That's expected. The table in the next section tells you what to do with each.

Step 2: opt every route out of validation

In development, Next.js now checks whether each route can render instantly and reports what blocks it. On an existing app that's a lot of reports at once. One codemod defers them all:

npx @next/codemod@canary cache-components-instant-false ./app 

It adds export const instant = false to every page, layout and default file. Use ./src/app if your project has a src folder. A wrong path reports "0 ok" rather than failing, so check the file count it prints.

instant = false means "this segment is allowed to block". It doesn't force the route to be dynamic.

Step 3: fix what can't be deferred

Synchronous, non-deterministic calls during prerender fail the build whether a route is opted out or not:

  • new Date() and Date.now()
  • Math.random()
  • crypto.randomUUID()

Move each one so it runs at request time: put the component that needs it inside <Suspense> and call connection() before it, or move it into a Client Component.

Step 4: convert one route at a time

Remove instant = false from a route, open it in development and read the dev overlay. Each insight names the component and offers one of two fixes:

  • Cache it: add 'use cache' to the component or data function.
  • Stream it: wrap it in <Suspense>.

Repeat until no route opts out. One thing that's easy to miss: insights don't appear in the HTTP response. The route still returns 200 with rendered HTML. You only see them in the dev overlay or the dev server log.

Old config to new code

You had Do this
dynamic = 'force-dynamic' Delete it. Uncached data already runs at request time
dynamic = 'force-static' Delete it, add 'use cache' with a cacheLife. Optionally add ensureStatic = 'navigation' to enforce it
dynamic = 'error' Delete it. Wrap the fetches that relied on default caching in 'use cache'
revalidate = 3600 'use cache' plus cacheLife('hours')
revalidate = 0 Delete it
fetchCache = ... Delete it. Cache at the data function instead
fetch(url, { cache: 'force-cache', next: { revalidate, tags } }) Plain fetch inside a 'use cache' function, with cacheLife and cacheTag
unstable_cache(...) Keeps working. Migrate later if you want
unstable_noStore() Delete it. Nothing is cached by default
dynamicParams = false Delete it (it fails the build). Call notFound() for params that don't resolve
revalidateTag('posts') revalidateTag('posts', 'max'). A cache profile is now required

A typical data function, before and after:

// Before export default async function Page() { const res = await fetch("https://api.example.com/data", { cache: "force-cache", next: { revalidate: 3600, tags: ["data"] }, }); const data = await res.json(); return <div>...</div>; } 
// After import { cacheLife, cacheTag } from "next/cache"; async function getData() { "use cache"; cacheLife("hours"); cacheTag("data"); const res = await fetch("https://api.example.com/data"); return res.json(); } export default async function Page() { const data = await getData(); return <div>...</div>; } 

And a page that reads a cookie:

// Before: reading cookies at the top made the whole route dynamic export default async function Page() { const theme = (await cookies()).get("theme")?.value; return <Dashboard theme={theme} />; } 
// After: the page prerenders, only Dashboard streams at request time export default function Page() { return ( <Suspense fallback={<p>Loading...</p>}> <Dashboard /> </Suspense> ); } async function Dashboard() { const theme = (await cookies()).get("theme")?.value; // ... } 

The errors you'll probably hit

generateStaticParams returning an empty array. Returning [] used to mean "render every path on first visit". Now it's an error: return at least one real param so Next.js can prerender and validate the route. Paths you don't return are still served on request. Don't delete the export to silence the error, because that opts the route out of ISR and it renders on every request.

Awaiting params at the top of a page. For routes without generateStaticParams, pass the params promise down into a component inside <Suspense> and await it there. Awaiting at the top blocks the static shell.

Client hooks that read the URL. usePathname, useParams, useSelectedLayoutSegment and useSelectedLayoutSegments suspend on routes with dynamic params that aren't known yet. A nav bar or breadcrumb in a shared layout is the usual culprit. Wrap the smallest component that reads the hook in <Suspense>, or the build fails. useSearchParams always needs a boundary.

A theme or language attribute on the html element. If a cookie drives lang, dir or data-theme in the root layout, reading it on the server makes the whole tree request-bound and there's nothing left to wrap. The documented fix is a small inline script in the head that sets the attribute before paint.

updateTag outside a Server Action. It throws. In Route Handlers and webhooks, use revalidateTag with a cache profile. Use updateTag after a mutation when the user must see their own change immediately.

Two behaviour changes that don't show up as errors

Your cache is less durable by default. The old fetch Data Cache and unstable_cache persist across deployments and across serverless instances. use cache stores entries in memory by default: they're dropped when the instance goes away and are scoped to one deployment. If you moved an expensive call to use cache and it recomputes more often than it used to, this is the likely reason. Use use cache: remote or a cache handler for storage that outlives an instance, and still expect values to recompute after each deploy.

React.cache no longer shares across cached functions. It still dedupes calls within a render, but each use cache function has its own isolated scope. If one cached function preloads data for another, they won't share the result.

Should you migrate now or wait?

Migrate now if you're on 16 already and have a few quiet weeks. The incremental path means the app builds and ships at every step, and you can stop halfway.

Wait a little if you're mid-launch, or still on Next.js 15. Get to 16 first; that upgrade is its own piece of work.

Either way, new routes you write from today can follow the new model, so the pile doesn't grow.

FAQ

Do I have to migrate?

Not today. It's opt-in on 16 for existing apps. The 16.4 release notes say it becomes the default in Next.js 17.

Will my existing fetch and unstable_cache caching break?

No. The migration guide says both keep working with Cache Components enabled, and migrating unstable_cache is optional.

Does instant = false make my route slower?

It changes nothing about how the route renders. It only tells Next.js not to report that segment for blocking. A route that can be prerendered still ships a static shell.

Is ensureStatic the same as force-static?

No. force-static made cookies() and headers() return empty values. ensureStatic (16.4 and later) leaves them alone and reports an error if request-specific work stops the route from being prerendered.

Sources

Want this migration done for you, route by route, with nothing breaking in production? Get in touch. Related: fixing Core Web Vitals in Next.js.

Originally published on vishalsolanki.online.