Skip to main content
Published
Next.jsCache ComponentsPPR

Migrating to Next.js Cache Components

Notes from migrating an App Router product onto Cache Components: use cache, Suspense shells, remote vs in-memory cache, and the gotchas that show up in production.

I have started migrating an App Router product to Next.js Cache Components. It feels powerful. The model is honest: prerender a static shell, stream what cannot be known yet, and mark the pieces you want cached with use cache. Partial prerendering stops being an experiment and becomes the default shape of a route.

That power comes with rules. Miss them and you get slow builds, empty shells that never fill, or caches that look shared when they are not. Here is what I am learning on that migration.

What changes when you turn it on

With cacheComponents enabled, data fetching is dynamic by default. You opt into caching at the page, component, or function level. Next.js prerenders a static HTML shell and streams dynamic holes behind Suspense. The old experimental.ppr flag is gone. Cache Components owns that behaviour.

You also get use cache, cacheLife, and cacheTag as first-class tools. The migration is less about memorising APIs and more about redrawing where request-time work is allowed to live.

next.config.mjsjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  cacheComponents: true,
};
 
export default nextConfig;
Enable Cache Components. Node.js runtime only.

Gotcha 1: request-time work must sit behind Suspense or use cache

cookies(), headers(), searchParams, and uncached fetches cannot finish during prerender. Cache Components expects you to either wrap that work in a Suspense boundary or move pure data work into a use cache scope with serializable inputs.

If you leave request-time access in the static shell, the dev overlay will tell you. Listen to it. The goal is every route still producing a shell that can be served immediately.

app/dashboard/page.tsxtsx
import { Suspense } from "react";
import { cookies } from "next/headers";
 
async function AccountPanel() {
  const jar = await cookies();
  const session = jar.get("session")?.value;
  const account = await loadAccount(session);
 
  return <AccountSummary account={account} />;
}
 
export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<AccountSkeleton />}>
        <AccountPanel />
      </Suspense>
    </main>
  );
}
Shell stays static. Personalised panel streams after the request-time read.

Gotcha 2: do not call cookies or headers inside use cache

use cache wants serializable arguments and serializable output. Reading cookies() or headers() inside the cached scope fails. Pass the values you need as arguments from outside the boundary.

That refactor feels pedantic until it saves you from caching the wrong user's data under a shared key.

lib/catalog.tstsx
async function getRegionCatalog(region: string) {
  "use cache";
  cacheTag(`catalog:${region}`);
  cacheLife("hours");
 
  return cms.getCatalog(region);
}
 
export async function CatalogSection() {
  const jar = await cookies();
  const region = jar.get("region")?.value ?? "uk";
 
  const catalog = await getRegionCatalog(region);
  return <CatalogList items={catalog} />;
}
Read request data outside. Cache the pure fetch inside.

Gotcha 3: default use cache is in-memory

On multi-instance serverless hosts, plain use cache lives in process memory. When the instance goes away, so does the entry. You can still get durable HTML side effects through the static shell and ISR-style regeneration. Runtime cache hits across instances are a different story.

If the product needs a shared runtime cache for expensive CMS or location payloads, look at use cache: remote and a real cache handler. Do not assume use cache alone behaves like Redis.

  • use cache: good for work that can join the static shell or survive on one instance.
  • use cache: remote: shared durable cache across instances, with latency and cost trade-offs.
  • use cache: private: rare, for request-private cached work you cannot refactor cleanly.

Gotcha 4: builds hang when the cache waits on the uncacheable

If a use cache function awaits a Promise created outside the boundary that depends on request-time or uncached data, prerender can time out. The error message is blunt once you have seen it once.

Keep the cached function self-contained. Fetch inside it, or pass already-resolved serializable values in. Do not close over a hanging request Promise.

Gotcha 5: dynamic params need a plan

When params are known at build time, the shell can include concrete content and stream only the remaining holes. When they are not, you get an App Shell: shared chrome with param-specific parts behind fallbacks. That is fine for long-tail location URLs. It surprises teams who expected every slug to look fully static on first request.

Use generateStaticParams for the high-traffic set. Let the long tail warm after first visit where ISR-style behaviour fills in. Prefetch matters too: for cached content that depends on URL data, prefetch={true} on Link is sometimes required to bring that work into the client navigation path.

app/locations/[slug]/page.tsxtsx
export async function generateStaticParams() {
  const top = await cms.getTopLocationSlugs(200);
  return top.map(slug => ({ slug }));
}
 
export default async function LocationPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
 
  return (
    <main>
      <Suspense fallback={<LocationSkeleton />}>
        <LocationBody slug={slug} />
      </Suspense>
    </main>
  );
}
Prerender the popular set. Stream or warm the rest.

Gotcha 6: invalidation is now your product contract

cacheTag and cacheLife are not optional decoration on a CMS-backed site. If editors publish and the page stays stale, that is a migration bug, not a CMS bug.

Centralise tags. Name them by domain, not by component. Revalidate on webhook. Keep TTLs honest for content that changes hourly versus catalogues that change weekly.

lib/cache-tags.tsts
export const tags = {
  location: (slug: string) => `location:${slug}`,
  catalog: (region: string) => `catalog:${region}`,
  layout: "shell:marketing",
} as const;
 
export async function getLocation(slug: string) {
  "use cache";
  cacheTag(tags.location(slug));
  cacheLife("hours");
  return cms.getLocation(slug);
}
A small tag registry beats scattered string literals.

Gotcha 7: runtime and edge assumptions

Cache Components expects the Node.js runtime. Routes still pinned to the deprecated edge runtime need a plan before you flip the flag. Self-hosted setups also need to understand cacheHandlers if you rely on remote cache.

How I am approaching the migration

I am not converting the whole tree in one PR. First the config flag on a branch. Then marketing and content shells, where static HTML pays rent immediately. Then shared catalog fetches behind use cache and tags. Authenticated or highly personalised panels stay outside shared cache and stream behind Suspense.

Each step has to survive a production build and a direct document request that shows a real shell. Client navigations get a second pass. Cache Components now validates those paths more loudly, which is a feature.

  • Start with public content routes.
  • Extract pure data loaders into use cache with tags.
  • Keep session reads and private dashboards dynamic behind Suspense.
  • Prove invalidation with a real publish webhook before calling the migration done.

Is it worth it?

Yes, for a content-heavy App Router product that already wants PPR, tagged CMS caching, and clearer boundaries between static chrome and dynamic holes. The model matches how those pages already need to behave.

The cool part is real. The gotchas are also real. Treat Cache Components as an architecture migration, not a one-line config win, and it stays powerful instead of mysterious.

More writing

Availability

Open to opportunities

Open to senior product engineer roles with real ownership across the stack. Security-minded by default.

UK-based · Remote / hybrid · Permanent or contract