
Next.js Migrating to Cache Components
If you've been running a Next.js App Router project for a while, your codebase almost certainly has a scattering of export const dynamic = 'force-dynamic', export const revalidate = 3600, and maybe a fetchCache override or two, sprinkled across pages and layouts wherever someone needed to nudge the rendering behavior in a particular direction. That whole system — route segment configs governing an implicit, page-wide caching decision — is what Cache Components replaces with something more explicit: caching becomes something you opt into, at the exact function or component boundary where it matters, via the use cache directive and its companion APIs.
Migrating an existing app to this model isn't a rewrite, but it's not nothing either. This article walks through both ways to do it — using Vercel's own migration skill, and doing it by hand — and goes through every route segment config and API you're likely to be replacing.
The mental shift, in one sentence
Under the old model, a route's rendering behavior was a page-level or layout-level setting — dynamic, static, or somewhere in between via ISR — configured with exports like dynamic and revalidate. Under Cache Components, everything is dynamic and uncached by default, and you explicitly mark the specific pieces that should be cached with use cache, tagged and timed with cacheTag and cacheLife. The framework then validates, in development, whether each route actually renders instantly — and if it can't, tells you exactly which piece is blocking it.
That validation step is the part worth sitting with, because it changes migration from "guess what needs to change" into "follow the errors." You don't have to reason abstractly about which of your forty routes need attention; Next.js tells you, one insight at a time, as you enable the flag.
Option one: let a coding agent do it
Vercel publishes an official migration skill, next-cache-components-adoption, designed specifically to drive this migration with an AI coding agent — one feature at a time, checking in at each boundary rather than attempting the whole codebase blind. It supports two modes: incremental, which opens one mechanical PR that opts every route out of validation first, then ships each feature as its own follow-up PR; and direct, which converts everything on a single branch in place.
npx skills add vercel/next.js --skill next-cache-components-adoption
Then, in your agent of choice:
Adopt Cache Components in this project using the next-cache-components-adoption skill.
For a codebase of any real size, this is worth trying first, if only because the incremental mode automates exactly the manual "opt everything out, then convert one route at a time" workflow described below — it's the same strategy, just executed for you rather than by you.
Option two: migrate by hand
If you'd rather understand every step (or your agent doesn't have skill support), the manual path is:
- Enable Cache Components in
next.config.ts. - Decide your approach — convert every route immediately, or opt out and convert incrementally.
- Follow the validation errors and insights as they surface, replacing each route segment config with its Cache Components equivalent.
Your existing fetch caching and unstable_cache usage keep working as a separate layer throughout this process — nothing breaks the moment you flip the flag. The insights just start telling you what to change.
Enabling the flag
Cache Components requires Next.js 16. If you're behind that, work through the version upgrade guides first — there's no skipping ahead.
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;
If you were previously using experimental.dynamicIO or experimental.useCache, this flag replaces both outright. The moment it's on, any route segment still exporting dynamic, revalidate, or fetchCache will error at build time — which is your worklist, generated for you.
Adopting incrementally, rather than all at once
For anything beyond a small app, converting every route in one sitting is asking for trouble. The incremental path:
1. Enable the flag and strip the old configs. Routes that render instantly with no further changes need nothing else.
2. Opt out the routes that aren't ready with instant = false on whichever segment raised the insight:
// app/dashboard/layout.tsx
export const instant = false;
To apply this across the whole app in one pass rather than route by route, there's a codemod for exactly this:
npx @next/codemod@canary cache-components-instant-false ./app
Worth double-checking the reported file count against expectation here — a wrong path (forgetting ./src/app in a src/-structured project, say) reports 0 ok rather than throwing an error you'd actually notice.
It's important to understand what instant = false does and doesn't do. It marks a segment as allowed to block — it does not force the route to become fully dynamic, so a route that's genuinely prerenderable still ships a static shell even with the flag set. And critically, it does not clear synchronous I/O errors. new Date(), Math.random(), crypto.randomUUID() called during prerender still fail the build regardless of this opt-out — which brings us to the one step in this process you can't defer.
3. Fix synchronous I/O first — it genuinely can't wait. Any route calling these during prerender throws a build error, opt-out or not. The fix is moving the call out of the prerendered shell: wrap the part that needs it in <Suspense>, call connection() before the offending line so it's forced to request time, or move the logic into a Client Component entirely.
4. Convert routes one at a time, removing instant = false and resolving whatever insights appear by caching data with use cache or wrapping runtime reads in <Suspense>. Repeat until nothing opts out anymore.
Following the validation output
With Cache Components on, the dev overlay surfaces errors and insights during development, each naming the offending component and pointing at a fix, with clickable cards linking to patterns and trade-offs for that specific case. A quietly important detail: these insights don't show up in the HTTP response. An offending route still returns a 200 with fully rendered HTML in dev — the insight lives only in the dev overlay, the dev-server log, or the MCP get_errors tool (see the companion article on the Next.js MCP Server if you're using an agent-driven workflow). If you're not actively watching the overlay, it's easy to ship something that "worked" locally while quietly failing the instant-navigation bar.
The config-by-config replacement guide
This is the part you'll actually reference repeatedly during migration, so it's worth having as a lookup table before diving into each one individually:
| Old config/API | Replacement |
|---|---|
dynamic = 'force-dynamic' | Delete it — everything is dynamic by default now |
dynamic = 'force-static' | Delete it, then add use cache where needed |
revalidate | cacheLife() inside a use cache function |
fetchCache | Delete it — use cache handles this automatically |
fetch(..., { cache, next }) | use cache + cacheLife() + cacheTag() |
unstable_cache | use cache directly on the function |
unstable_noStore | Delete it — uncached is the default now |
runtime = 'edge' | Not supported; use the Node.js runtime, or Proxy for edge behavior |
experimental_ppr | Delete it — cacheComponents includes PPR now |
dynamicParams | Delete it — not compatible; use notFound() instead |
dynamic = "force-dynamic" → nothing
This one's the easiest possible case: just delete it. Every page is dynamic by default now, so the config was only ever restating the default.
dynamic = "force-static" → use cache with a long cacheLife
Remove the export first and let the build tell you what breaks. For uncached data access, wrap it with use cache and a long profile like 'max' to preserve the old always-static behavior:
// After
import { cacheLife } from "next/cache";
export default async function Page() {
"use cache";
cacheLife("max");
const data = await fetch("https://api.example.com/data");
return <div>...</div>;
}
For runtime data access (cookies(), headers()) inside a route that used to be force-static, the fix is different — you can't cache your way out of genuinely per-request data. Remove the runtime access, or accept that the route can no longer be fully static and wrap the dynamic part in <Suspense> instead.
revalidate → cacheLife
// Before
export const revalidate = 3600; // 1 hour
// After
import { cacheLife } from "next/cache";
export default async function Page() {
"use cache";
cacheLife("hours");
return <div>...</div>;
}
If your old numeric value doesn't map neatly onto one of the built-in profiles (seconds, minutes, hours, days, weeks, max), define a custom profile rather than rounding awkwardly — or redefine the default profile itself if your app's conventions consistently diverge from the built-ins.
fetchCache → nothing, use cache covers it
Any fetch inside a use cache scope is cached automatically — there's no separate fetch-cache-mode config to set anymore. Delete the export.
fetch cache options → cacheLife/cacheTag inside use cache
// Before
const res = await fetch("https://api.example.com/data", {
cache: "force-cache",
next: { revalidate: 3600, tags: ["data"] },
});
// After
async function getData() {
"use cache";
cacheLife("hours");
cacheTag("data");
const res = await fetch("https://api.example.com/data");
return res.json();
}
The persistence model changed here and it's genuinely easy to miss: the old fetch Data Cache persisted across deployments and serverless instances. use cache defaults to in-memory storage — scoped to a single deployment, discarded when the instance recycles. If your app depends on cached data surviving a redeploy, you need use cache: remote or a configured cache handler; the plain default won't give you that anymore.
unstable_cache → use cache directly
// Before
export const getUser = unstable_cache(
async (id: string) => db.query.users.findFirst({ where: eq(users.id, id) }),
["user"],
{ tags: ["users"], revalidate: 3600 },
);
// After
export async function getUser(id: string) {
"use cache";
cacheLife("hours");
cacheTag("users");
return db.query.users.findFirst({ where: eq(users.id, id) });
}
Notice the cache-key-prefix array (['user']) simply disappears — use cache derives the key automatically from the function's arguments, so there's nothing manual to maintain. Same persistence caveat as above applies here too.
On-demand revalidation: pick by intent, not habit
This is where the migration adds a genuinely new decision rather than a mechanical swap. You now choose between three invalidation APIs based on what behavior you actually want:
updateTag— for mutations the user needs to see reflected immediately (read-your-own-writes). Callable only from a Server Action.revalidateTag— for stale-while-revalidate; now requires a cache profile as its second argument (e.g.revalidateTag('posts', 'max')). Works from Server Actions and Route Handlers.revalidatePath— unchanged from before.
// app/actions.ts
"use server";
import { updateTag } from "next/cache";
export async function createPost(formData: FormData) {
// ...create the post...
updateTag("posts"); // user sees it on the very next request
}
updateTag isn't exclusive to Cache Components — it works under the old model too — but migration is a natural moment to start using it wherever a mutation's result needs to be visible right away, rather than reaching for a plain revalidateTag out of habit.
unstable_noStore → delete it
Uncached is the default now, so a call that used to opt a component out of caching is simply redundant. If the component genuinely needs request-time data, call connection() and wrap it in <Suspense> instead.
generateStaticParams gets stricter
Returning an empty array used to mean "defer everything to runtime" — under Cache Components, it now errors. You must return at least one param so Next.js has something concrete to prerender and validate against. Paths you don't return still work fine; they get a static shell and stream the rest at request time.
dynamicParams is gone entirely
Exporting it fails the build outright. If you were using dynamicParams: false to reject unknown params, replace that with an explicit notFound() call inside the page when the param doesn't resolve.
Awaiting params and searchParams moves inside <Suspense>
To let the static shell prerender even when specific params are unknown at build time, pass the params promise straight into a <Suspense>-wrapped component instead of awaiting it at the top of the page:
import { Suspense } from "react";
export default function Page({ params }: PageProps<"/blog/[slug]">) {
return (
<Suspense fallback={<div>Loading...</div>}>
<Post params={params} />
</Suspense>
);
}
async function Post({ params }: Pick<PageProps<"/blog/[slug]">, "params">) {
const { slug } = await params;
// ...
}
The same logic applies to usePathname, useParams, useSelectedLayoutSegment(s), and — always, unconditionally — useSearchParams, since search params can only ever be known at request time.
cookies, headers, searchParams need explicit boundaries
Previously, reading these anywhere opted the entire route into dynamic rendering, silently. Now, reading them outside a <Suspense> boundary is flagged directly. Push the read down into the smallest component that actually needs it:
import { cookies } from "next/headers";
import { Suspense } from "react";
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Dashboard />
</Suspense>
);
}
async function Dashboard() {
const theme = (await cookies()).get("theme")?.value;
// ...
}
One case worth flagging separately: if a cookie or header value sets an attribute on <html> itself (lang, dir, data-theme) in the root layout, there's no child component to wrap in <Suspense> — the whole subtree becomes request-bound. The fix there is a small inline <script> in <head> that sets the attribute client-side before paint, keeping the shell static (see the companion article on preventing flash before hydration).
GET Route Handlers follow the same rules as pages now
// Before
export const dynamic = "force-static";
export async function GET() {
const products = await db.query("SELECT * FROM products");
return Response.json(products);
}
// After
async function getProducts() {
"use cache";
cacheLife("hours");
return db.query("SELECT * FROM products");
}
export async function GET() {
return Response.json(await getProducts());
}
The directive can't sit directly on the GET export, so the pattern is always: extract the data access into its own use cache function, call it from the handler. One gotcha worth knowing: reading uncached or runtime data bails out of prerendering by throwing — an existing try/catch around your handler logic will catch that, which is usually fine, but can add noisy logs to your build output. experimental.hideLogsAfterAbort: true quiets that specifically.
runtime = 'edge' isn't supported at all
Cache Components requires the Node.js runtime, full stop. If you need edge-like behavior for specific routes, that's what Proxy is for now — not the page's own runtime export.
The one behavior change that isn't a config swap: state preservation
This is worth calling out because it's not something you migrate — it just starts happening once the flag is on, and it can genuinely surprise you. With Cache Components, Next.js preserves routes using React's <Activity> component in hidden mode rather than unmounting them on navigation. Effects still clean up and re-run normally, but useState values, form inputs, and scroll position stop resetting when a user navigates away and back.
If any of your existing code implicitly relied on unmounting to reset state — a dropdown that used to just disappear when you navigated away, a dialog whose focus-on-open effect won't refire because its state never actually reset — you'll need to add deliberate reset logic: close dropdowns in a useLayoutEffect cleanup, derive dialog open/closed state from the URL instead of local state, or reset form results explicitly in the submit handler. This is genuinely easy to miss during migration because nothing errors — the app just starts behaving slightly differently in ways that only show up as "huh, that's odd" during manual testing.
Key Takeaways
| What changes | What to do |
|---|---|
| Route-level dynamic/static setting | Delete it — cache explicitly per-function with use cache instead |
revalidate | cacheLife() |
unstable_cache | use cache directly, key derived automatically |
| Cache persistence across deploys | No longer automatic — use use cache: remote or a cache handler if you need it |
Empty generateStaticParams() | Now errors — return at least one param |
| Reading cookies/headers/searchParams | Must be inside <Suspense>, pushed to the smallest component |
| Component state on navigation | Now preserved by default — add explicit resets where you relied on unmounting |
| Fastest path for a real codebase | Try the next-cache-components-adoption skill before doing it by hand |
Migrating to Cache Components is less about learning new syntax and more about accepting a new default: nothing is cached until you say so, and nothing about a route's rendering strategy is implicit anymore. The validation-driven workflow — enable the flag, follow the insights, convert one route at a time — is what makes that change tractable instead of overwhelming, even on a codebase you didn't build from scratch.


