DevStacked
Next.js FundamentalsAugust 30, 202623 min read

Next.js Cache Components Migration Guide (2026)

You flip on cacheComponents: true expecting a quick performance win, and instead your build explodes with red errors about dynamic, revalidate, and fetchCache not being allowed anymore. Then, once those clear, the dev overlay starts flagging routes that "won't render instantly" — pointing at cookies(), searchParams, and database calls you've had in production for months.

Nothing is actually broken. This is exactly how the migration is supposed to work — Next.js is walking you through moving from implicit caching (where the framework guessed what should be static) to explicit caching (where you say so directly). This guide walks through that migration end to end: every route segment config you'll need to replace, what to swap it for, and how to do it without rewriting your whole app in one sitting.

💡 New to Cache Components as a concept? This guide is about migrating an existing app. If you want the "what and why" first, read Next.js Rendering Strategies Explained, which covers SSR, SSG, ISR, and Cache Components from scratch.


Why This Migration Exists

Before Cache Components, Next.js decided whether a route was static or dynamic for you, by inspecting what your code touched — a fetch call, a cookies() read, a route segment config. That worked, but it was implicit: two developers could read the exact same route and disagree about whether it was static or dynamic, and a small code change could silently flip a page from instant to slow.

Cache Components flips the default: everything is dynamic (rendered per-request) unless you explicitly mark it as cached. You use 'use cache' to say "cache this," cacheLife() to say "for how long," and <Suspense> to say "this part is allowed to be slow, stream it in separately." Nothing is guessed anymore.

This is also what powers Partial Prerendering (PPR) as the default behavior — a page can ship a static, instantly-served HTML shell while a personalized or slow-loading section streams in right behind it, all in one route, with no extra config.


Prerequisites

This guide assumes:

  • Next.js 16 — Cache Components requires it. If you're on Next.js 15 with experimental.dynamicIO or experimental.ppr, upgrade to 16 first using the official upgrade guide.
  • App Router — Cache Components is an App Router feature only. It does nothing for a pages/ directory. A hybrid app (both pages/ and app/) is fine — the flag only affects app/ routes.
  • TypeScript in strict mode (the examples below use it, but everything works the same in plain JS).
  • Node.js runtime — Cache Components does not support runtime = 'edge'. More on this later.

Step 1: Enable the cacheComponents Flag

Turn it on in your config file.

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
};

export default nextConfig;

What's happening here: this single flag replaces three older, separate flags — experimental.dynamicIO, experimental.useCache, and experimental.ppr. If your next.config.ts still has any of those, delete them; cacheComponents supersedes all three.

⚠️ Common Mistake: Leaving experimental.ppr or experimental_ppr route segment exports in place after adding cacheComponents. Next.js 16 removed the experimental.ppr flag entirely — PPR is just what cacheComponents does now. Run npx @next/codemod@canary to clean up leftover experimental_ppr segment exports automatically (see the codemod list in the official codemods reference).

The moment you save this file, any route that still exports dynamic, revalidate, or fetchCache will fail to build. That's expected — those three route segment configs no longer exist under Cache Components, and the rest of this guide is about replacing each one.


Step 2: Pick Your Migration Strategy

You have two realistic paths, and the right one depends on how big your app is.

Option A — Migrate everything now

Fine for small apps or a handful of routes. Go through each error the build throws, fix it, move to the next.

Option B — Migrate incrementally (recommended for real apps)

Use the instant route segment config to defer validation on routes you're not ready to touch yet, so the rest of your app keeps shipping while you convert routes one at a time.

// app/dashboard/layout.tsx
export const instant = false;

What's happening here: instant = false tells Next.js "this segment is allowed to block on request-time work — don't fail the build over it yet." It does not force the route to become fully dynamic; a route that's actually prerenderable still ships a static shell even with this set. It just silences the validation error so you can come back to it later.

Next.js ships a codemod that adds this opt-out to every page, layout, and default file in one pass:

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

💡 Tip: If your app lives under src/app, pass that exact path instead — npx @next/codemod@canary cache-components-instant-false ./src/app. A wrong path silently reports 0 ok instead of erroring, so check the file count in the output before assuming it worked.

With that run, your whole app builds and serves again. Then work through routes one at a time: remove instant = false from a segment, fix whatever the dev overlay flags, repeat.

⚠️ instant = false doesn't fix everything. Synchronous, non-deterministic calls — new Date(), Date.now(), Math.random(), crypto.randomUUID() — used during prerender still throw a hard build error that this opt-out does not suppress. Move the call into a component wrapped in <Suspense> (calling connection() first) or into a Client Component instead.


Step 3: Remove dynamic = 'force-dynamic'

This one's the easiest fix in the whole migration — you just delete it.

// Before
export const dynamic = "force-dynamic";

export default function Page() {
  return <div>...</div>;
}
// After — every route is already dynamic by default
export default function Page() {
  return <div>...</div>;
}

Why: under Cache Components, nothing is cached unless you say so with 'use cache'. "Dynamic by default" is now the baseline behavior, so force-dynamic has nothing left to force.


Step 4: Replace dynamic = 'force-static' with 'use cache'

Start by deleting the export, then let the build tell you what's actually uncached.

// Before
export const dynamic = "force-static";

export default async function Page() {
  const data = await fetch("https://api.example.com/data");
  return <div>...</div>;
}
// 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>;
}

What's happening here: use cache at the top of the function marks it as a cache boundary — everything inside, including the fetch call, gets cached automatically. cacheLife('max') is the closest built-in profile when you want long-lived caching, but it is not a literal equivalent of the old force-static behavior.

If your force-static route reads runtime data (cookies(), headers()), you can't keep it fully static anymore — that data genuinely only exists per-request. The fix there is wrapping just that part in <Suspense>, covered in Step 9.


Step 5: Replace revalidate with cacheLife

A numeric revalidate export becomes a cacheLife() call inside a 'use cache' function.

// Before
export const revalidate = 3600; // 1 hour

export default async function Page() {
  return <div>...</div>;
}
// After
import { cacheLife } from "next/cache";

export default async function Page() {
  "use cache";
  cacheLife("hours");

  return <div>...</div>;
}

What's happening here: cacheLife accepts either a named profile string or a custom object. The built-in profiles — seconds, minutes, hours, days, weeks, max — map to sensible defaults:

Profilestalerevalidateexpire
seconds30s1s1 min
minutes5 min1 min1 hour
hours5 min1 hour1 day
days5 min1 day1 week
weeks5 min1 week30 days
max5 min30 days1 year

If none of the presets match your old revalidate value exactly, pick the closest one, or define a custom profile in next.config.ts:

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    blog: {
      stale: 3600, // 1 hour — how long the client can use a cached value without checking
      revalidate: 900, // 15 minutes — how often the server refreshes in the background
      expire: 86400, // 1 day — max age before the entry is dropped entirely
    },
  },
};

export default nextConfig;

stale controls how long the client Router Cache can reuse the result; it does not set the HTTP Cache-Control: max-age header.

// app/blog/page.tsx
import { cacheLife } from "next/cache";

export default async function BlogPage() {
  "use cache";
  cacheLife("blog"); // references the custom profile above

  return <div>...</div>;
}

💡 Tip: You can also redefine a built-in profile (including default) with the same name, if you want cacheLife('hours') to mean something different across your whole app. Document it somewhere obvious if you do — a teammate reading cacheLife('hours') will otherwise assume the stock 1-hour behavior.


Step 6: Delete fetchCache

This route segment config has no replacement because it has no purpose anymore.

// Before
export const fetchCache = "force-cache";
// After — just delete it
export default async function Page() {
  "use cache";
  // every fetch() inside this scope is cached automatically
  return <div>...</div>;
}

Why: fetchCache used to control whether fetch() calls were cached at the route level. Under Cache Components, caching is decided per-function by 'use cache', not globally per route — so there's nothing left for fetchCache to configure.


Step 7: Move fetch() Cache Options into 'use cache'

Move the caching policy into the use cache scope and replace fetch-level tags with cacheTag() where appropriate.

// 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>;
}

What's happening here: pulling the fetch into its own getData() function marked 'use cache' is the standard pattern — it keeps the cache boundary small and lets the page component stay a plain async function. cacheTag('data') is what lets you invalidate this specific cached result later (Step 9).

⚠️ Persistence difference worth knowing: the old fetch Data Cache persisted across deployments and serverless instances. By default, 'use cache' uses an in-memory cache isolated to the Next.js process. Multiple instances do not share that cache, and entries are lost when the process restarts. If you need caching that survives instance teardown, use 'use cache: remote' or configure a cache handler. Either way, expect values to recompute after a fresh deployment.


Step 8: Replace unstable_cache with 'use cache'

If you were wrapping database queries in unstable_cache, the migration is mechanical.

// Before
import { unstable_cache } from "next/cache";
import { db } from "@/lib/db";

export const getUser = unstable_cache(
  async (id: string) => {
    return db.query.users.findFirst({ where: eq(users.id, id) });
  },
  ["user"], // cache key prefix
  { tags: ["users"], revalidate: 3600 }
);
// After
import { cacheLife, cacheTag } from "next/cache";
import { db } from "@/lib/db";

export async function getUser(id: string) {
  "use cache";
  cacheLife("hours");
  cacheTag("users");

  return db.query.users.findFirst({ where: eq(users.id, id) });
}

What's happening here: the wrapper function becomes a plain function with a directive. Notice the manual ["user"] cache-key-prefix array is gone entirely — Next.js derives the cache key automatically from the function's arguments (and its closure), so you never write a cache key by hand.

Same persistence caveat as Step 7 applies: unstable_cache persisted across deployments; 'use cache' doesn't by default.


Step 9: Update Your Invalidation Calls

On-demand invalidation still works — you just have three tools now instead of one, and picking the right one matters.

  • updateTag — for a mutation where the user needs to see their own change immediately (read-your-own-writes). Only callable from a Server Action.
  • revalidateTag — for stale-while-revalidate behavior. Works in both Server Actions and Route Handlers, but now requires a cache profile as a second argument.
  • revalidatePath — unchanged from before.
// app/actions.ts
"use server";

import { updateTag } from "next/cache";

export async function createPost(formData: FormData) {
  // create the post in your database, then:
  updateTag("posts"); // the next render sees the new post immediately
}
// app/api/webhook/route.ts

// Before
import { revalidateTag } from "next/cache";

export async function POST() {
  revalidateTag("posts");
  return Response.json({ ok: true });
}
// app/api/webhook/route.ts

// After — a profile is now required
import { revalidateTag } from "next/cache";

export async function POST() {
  revalidateTag("posts", "max");
  return Response.json({ ok: true });
}

What's happening here: the bare, single-argument revalidateTag(tag) call is deprecated — it still technically compiles if you suppress the TypeScript error, but it may stop working in a future release. Pass max as the recommended default — it uses the profile's one-year expire value as the stale-serving window, so requests almost never block waiting on a fresh fetch after invalidation.

⚠️ Common Mistake: Reaching for revalidateTag after a mutation when you actually want updateTag. revalidateTag marks data stale and serves the old value while it refreshes in the background — a user who just submitted a form could still see their old data on the very next page load. If the user needs to see their own change right away, use updateTag inside the Server Action instead.


Step 10: Delete unstable_noStore

Remove unstable_noStore() in most migrations. If the operation must intentionally happen at request time and doesn't naturally depend on cookies(), headers(), or another request API, use connection() to opt that work out of prerendering.

// Before
import { unstable_noStore as noStore } from "next/cache";

export default async function Page() {
  noStore();
  const data = await db.query("...");
  return <div>...</div>;
}
// After
export default async function Page() {
  const data = await db.query("...");
  return <div>...</div>;
}

If a component genuinely needs to run at request time (not just "not be cached"), call connection() before the work and wrap it in <Suspense> — that's the real replacement when you need to force a dynamic boundary rather than just skip caching.


Step 11: Fix generateStaticParams

Two real changes here for dynamic routes.

An empty array is now an error

// Before — used to defer every path to runtime, now throws a build error
export async function generateStaticParams() {
  return [];
}
// After — return at least one param so Next.js can prerender a shell
export async function generateStaticParams() {
  const posts = await fetch("https://.../posts").then((res) => res.json());
  return posts.slice(0, 1).map((post: { slug: string }) => ({ slug: post.slug }));
}

Why: Cache Components needs generateStaticParams to produce at least one real param so it can prerender a static shell and confirm it's non-empty. Paths you don't return still work fine — Next.js prerenders a generic shell for unknown params and streams the real content at request time.

dynamicParams is no longer supported

// Before — this now fails the build entirely
export const dynamicParams = false;

Delete it. If you were using dynamicParams: false to reject unknown params, call notFound() inside the page instead, once you know the param doesn't resolve to real data.

Await params inside <Suspense>, not at the top

// Before — awaiting at the top blocks the whole static shell
export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  return <Post slug={slug} />;
}
// After
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;
  // ...
}

What's happening here: passing the params promise straight through as a prop, then awaiting it inside a <Suspense>-wrapped child, lets Next.js prerender the shell for unknown/unlisted params instead of blocking the entire page on resolving them first.

The same pattern applies to usePathname, useParams, useSelectedLayoutSegment, useSelectedLayoutSegments, and useSearchParams in Client Components — if the value depends on a dynamic param, wrap the smallest component that reads it in <Suspense> rather than the whole tree.


Step 12: Wrap cookies(), headers(), and searchParams in <Suspense>

This is the change you'll hit most often, because it's the most common source of "instant navigation" validation errors.

// Before — reading cookies at the top makes the whole route dynamic
import { cookies } from "next/headers";

export default async function Page() {
  const theme = (await cookies()).get("theme")?.value;
  return <Dashboard theme={theme} />;
}
// After — the page shell prerenders; only Dashboard streams at request time
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;
  // ...
}

What's happening here: moving the cookies() read into a separate component, and wrapping just that component in <Suspense>, lets everything outside it (the page's static shell) prerender normally. Only the part that genuinely needs per-request data waits until the actual request.

The same move applies to searchParams, which your page receives as a promise:

import { Suspense } from "react";

export default function Page({ searchParams }: PageProps<"/">) {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Results searchParams={searchParams} />
    </Suspense>
  );
}

async function Results({ searchParams }: Pick<PageProps<"/">, "searchParams">) {
  const { query } = await searchParams;
  // ...
}

💡 Tip: If a cookie or header controls an attribute on <html> in the root layout (lang, dir, data-theme), there's no child element to wrap in <Suspense> — reading it there makes the whole subtree request-bound by nature. Use an inline <script> in <head> that sets the attribute client-side before paint instead; see Preventing flash before hydration for the pattern.


Step 13: Update GET Route Handlers

Route Handlers follow the same rules as pages now.

// app/api/products/route.ts

// Before
export const dynamic = "force-static";

export async function GET() {
  const products = await db.query("SELECT * FROM products");
  return Response.json(products);
}
// app/api/products/route.ts

// After
import { cacheLife } from "next/cache";

export async function GET() {
  const products = await getProducts();
  return Response.json(products);
}

async function getProducts() {
  "use cache";
  cacheLife("hours");

  return db.query("SELECT * FROM products");
}

What's happening here: the 'use cache' directive can't be applied to the exported GET function itself — it goes on a helper function the handler calls instead.

💡 Tip: Reading uncached or runtime data in a GET handler bails out of prerendering by throwing. If you already wrap your handler body in try/catch, that block will catch this bail-out too — which can add noisy logs during the build. Set experimental.hideLogsAfterAbort: true in next.config.ts if that gets in your way.


Step 14: Handle generateMetadata and generateViewport

These follow the same component rules — cache external data, or explicitly mark the page as needing runtime data.

// Before
export async function generateMetadata() {
  const { title, description } = await db.query("site-metadata");
  return { title, description };
}
// After — cache it, same as any other external data fetch
export async function generateMetadata() {
  "use cache";
  const { title, description } = await db.query("site-metadata");
  return { title, description };
}

If your metadata genuinely depends on runtime data (a signed-in user's name, a cookie), you can't wrap generateMetadata itself in <Suspense> — instead, add a small dynamic marker component to the page so the static content still prerenders while the metadata resolves separately:

import { Suspense } from "react";
import { connection } from "next/server";

export async function generateMetadata() {
  // reads runtime data — intentional
  return { title: "Personalized Title" };
}

async function Connection() {
  await connection();
  return null;
}

export default function Page() {
  return (
    <>
      <article>Static content</article>
      <Suspense>
        <Connection />
      </Suspense>
    </>
  );
}

For the full breakdown of trade-offs here, see generateMetadata with Cache Components in the official reference — this is one of the trickier corners of the migration and worth reading directly if metadata personalization matters for your app. If you haven't set up metadata at all yet, How to Add Metadata in Next.js 16 covers the basics first.


Step 15: Remove runtime = 'edge'

Cache Components requires the Node.js runtime — Edge isn't supported.

// Before — no longer supported under Cache Components
export const runtime = "edge";

Delete the export (Node.js is the default anyway). If you specifically need Edge behavior for a route — geolocation checks, ultra-low-latency redirects — reach for Proxy (the renamed middleware.ts) instead of the Edge runtime on the route itself.


A Gotcha After the Build Passes: UI State No Longer Resets

Once your routes build cleanly, there's one behavior change worth testing manually, because it won't show up as a build error: Cache Components preserves component state across client-side navigation using React's <Activity> component, instead of unmounting the page you navigated away from.

In practice: a useState value, an open dropdown, or scroll position no longer resets just because the user clicked to a different page and back. This is usually a win, but it can surface bugs in code that assumed navigating away meant a clean remount — a dropdown that used to close automatically, a dialog whose focus-on-open effect no longer refires because its state was preserved rather than reset.

⚠️ Common Mistake: Assuming a modal or dropdown will close itself on navigation the way it always used to. If a component should reset when its route or user context changes, reset that state explicitly based on the relevant prop/route change instead of relying on unmount behavior.


Migration Checklist

Work through this in order for each route:

  • cacheComponents: true set in next.config.ts; old experimental.dynamicIO / experimental.useCache / experimental.ppr removed
  • dynamic = 'force-dynamic' deleted everywhere
  • dynamic = 'force-static' replaced with 'use cache' + cacheLife('max')
  • revalidate exports replaced with cacheLife() calls
  • fetchCache exports deleted
  • Individual fetch() cache options (cache, next.revalidate, next.tags) moved into 'use cache' functions with cacheLife / cacheTag
  • unstable_cache calls converted to 'use cache' functions
  • revalidateTag calls updated to pass a cache profile (revalidateTag(tag, 'max'))
  • Mutations that need read-your-own-writes use updateTag instead of revalidateTag
  • unstable_noStore() calls removed
  • generateStaticParams returns at least one param; dynamicParams export removed
  • params and searchParams awaited inside <Suspense>-wrapped children, not at the top of the page
  • cookies() / headers() reads pushed into <Suspense>-wrapped components
  • GET Route Handlers use a 'use cache' helper instead of dynamic = 'force-static'
  • generateMetadata / generateViewport cache external data or use the dynamic-marker pattern for runtime data
  • No routes still export runtime = 'edge'
  • UI that relied on unmount-to-reset behavior (dropdowns, dialogs, forms) tested manually after migration

Frequently Asked Questions

Do I have to migrate my whole app at once?

No. Use export const instant = false on the layouts or pages that aren't ready, run the cache-components-instant-false codemod to apply it everywhere in one pass, and convert routes one at a time from there. Your app keeps building and serving the whole time.

What happens to my existing fetch cache and unstable_cache data during migration?

Existing unstable_cache usage is still supported, but Next.js 16 recommends migrating it to use cache. The old fetch/Data Cache model and Cache Components can coexist during migration, but route segment configs such as dynamic, revalidate, and fetchCache must be migrated.

Why does my build fail on new Date() even with instant = false set?

instant = false only defers the validation check for whether a route renders instantly — it doesn't clear synchronous, non-deterministic calls like new Date(), Math.random(), or crypto.randomUUID() used during prerendering. Those still throw a hard build error. Move the call into a <Suspense>-wrapped component (calling connection() first) or into a Client Component.

Is 'use cache' the same as the old fetch Data Cache?

No, and this trips people up. The old fetch Data Cache persisted across deployments and serverless instances. The default use cache handler uses an in-memory LRU cache isolated to each Next.js process. It isn't shared across multiple instances and is lost when the process restarts. Use use cache: remote or a cache handler if you need durability across deployments.

My revalidateTag(tag) call now shows a TypeScript error — what changed?

The single-argument form is deprecated. revalidateTag now requires a cache profile as a second argument — revalidateTag('posts', 'max') uses the recommended stale-while-revalidate profile. The max cache profile has a 30-day server revalidation interval and a 1-year expiration window.

Does Cache Components work with the Pages Router?

No — it's an App Router-only feature. If your project has both pages/ and app/ directories, cacheComponents: true only affects the app/ routes; pages/ routes are completely unaffected and need no changes.

Can I use Cache Components on Vercel's Edge Runtime?

No. Cache Components requires the Node.js runtime. Remove any runtime = 'edge' export from routes you're migrating — if you specifically need Edge-level behavior, use Proxy (proxy.ts) instead of the route-level Edge runtime.


Wrapping Up

The Cache Components migration looks scary because it surfaces every implicit assumption your app was making about what's static versus dynamic, all at once, as build errors. But each fix is genuinely mechanical: revalidate becomes cacheLife, unstable_cache becomes a 'use cache' function, and any runtime data read (cookies(), searchParams, a live database query) gets pushed into a <Suspense> boundary so it doesn't block the rest of the page.

Start with instant = false on everything you're not ready to touch, get the app building again, then convert routes one at a time using the checklist above. You'll come out the other side with a codebase where every cache decision is explicit instead of guessed — and a meaningfully faster app for it.

Continue Learning

Useful Resources

Next.jsCache ComponentsPPRuse cachecacheLifeApp RouterTypeScriptMigration
Share On