BuildWithMatija
  1. Home
  2. Blog
  3. Next.js
  4. Next.js 16 Cache Components: The Complete unstable_cache Migration Guide

Next.js 16 Cache Components: The Complete unstable_cache Migration Guide

Why Next.js 16's 'use cache' directive replaces unstable_cache and how to migrate to compiler-driven server caching

1st March 2026·Updated on:18th July 2026··
Next.js
Next.js 16 Cache Components: The Complete unstable_cache Migration Guide

⚡ Next.js Implementation Guides

In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.

No spam. Unsubscribe anytime.

Related Posts:

  • •Fix the Uncached Data Error in Next.js 16 — 2 Proven Fixes
  • •How to Use Canonical Tags and Hreflang in Next.js 16
  • •Next.js revalidateTag vs updateTag: Cache Strategy Guide
📄View markdown version
0

Frequently Asked Questions

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in

Fix the Uncached Data Error in Next.js 16 — 2 Proven Fixes
Fix the Uncached Data Error in Next.js 16 — 2 Proven Fixes

5th March 2026

How to Use Canonical Tags and Hreflang in Next.js 16
How to Use Canonical Tags and Hreflang in Next.js 16

6th September 2025

Next.js revalidateTag vs updateTag: Cache Strategy Guide
Next.js revalidateTag vs updateTag: Cache Strategy Guide

3rd March 2026

Contents

  • The short version
  • 1. The new mental model
  • The previous question
  • The Cache Components question
  • The four rendering buckets
  • 2. What changes when `cacheComponents` is enabled
  • 3. The key APIs and what each one means
  • `'use cache'`
  • `cacheLife()`
  • `cacheTag()`
  • `updateTag()`
  • `revalidateTag()`
  • `connection()`
  • 4. The direct `unstable_cache` migration pattern
  • Before
  • After
  • What changed
  • Do not preserve a redundant manual key prefix
  • 5. The persistence decision you must make
  • Plain `'use cache'`
  • `'use cache: remote'`
  • Migration choice matrix
  • 6. Tenant-safe and locale-safe caching
  • Recommended tag hierarchy
  • Cache the resolved locale, not request machinery
  • 7. Suspense architecture for dynamic work
  • `loading.tsx` versus manual Suspense
  • Wrong: block the whole page
  • Right: isolate the request-time island
  • Fresh database reads
  • Boundary placement rule
  • Fallback rule
  • 8. Dynamic `params` and `searchParams`
  • 9. Layouts and internationalization
  • Rules
  • 10. Metadata, route handlers, and other special surfaces
  • `generateMetadata()` and `generateViewport()`
  • GET Route Handlers
  • Draft Mode
  • Node runtime
  • 11. Build-time execution and module-scope side effects
  • 12. Payload CMS reference architecture
  • Payload admin and other vendor-provided routes
  • Payload webhook invalidation
  • 13. Cache key and tag design rules
  • Cache key rules
  • Tag rules
  • High-cardinality example
  • 14. Security and personalization
  • Never remotely share
  • Preferred authenticated pattern
  • 15. Client Components and preserved navigation state
  • 16. A safe migration plan
  • Phase 0: establish a baseline
  • Phase 1: inventory the old model
  • Phase 2: classify every read
  • Phase 3: enable the flag without rewriting everything
  • Phase 4: migrate shared public data
  • Phase 5: isolate request-time work
  • Phase 6: migrate layouts and special surfaces
  • Phase 7: choose durable cache infrastructure
  • Phase 8: remove compatibility code
  • 17. Verification and debugging
  • Always run a production build
  • Verification matrix
  • Prove actual reuse
  • 18. Common failures and their real fixes
  • Failure: `Uncached data was accessed outside of <Suspense>`
  • Failure: `blocking-route`
  • Failure: every route under a layout blocks
  • Failure: Suspense does not improve anything
  • Failure: fallback recurses or still blocks
  • Failure: tenant content crosses boundaries
  • Failure: cache hit rate collapses after migration
  • Failure: invalidation appears delayed
  • Failure: `cacheLife()` throws
  • Failure: cached function cannot read cookies or headers
  • Failure: build hangs
  • 19. The mistakes-to-avoid checklist
  • 20. Code-review template
  • Intent
  • Keys and scope
  • Lifetime and invalidation
  • Rendering
  • Operations
  • 21. Recommended end state
  • Frequently asked questions
  • Does `'use cache'` completely replace `unstable_cache`?
  • Does Suspense cache its children?
  • Should I add `connection()` to every dynamic page?
  • Why am I getting invalid cache-tag warnings?
  • What should I do if TypeScript rejects `cacheLife('cms')`?
  • Why does Redis connect during `next build`?
  • Should authenticated data use `'use cache: remote'`?
  • Can I migrate Payload admin routes like normal frontend pages?
  • Primary official documentation
On this page:
  • The short version
  • 1. The new mental model
  • 2. What changes when `cacheComponents` is enabled
  • 3. The key APIs and what each one means
  • 4. The direct `unstable_cache` migration pattern
Build with Matija logo

Build with Matija

Senior-led B2B websites, applications, content systems, and digital infrastructure. Business-first, full-stack, AI-assisted, no handoffs.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•All rights reserved•Privacy Policy•Terms of Service
BuildWithMatija
Get In Touch

I recently migrated a production Next.js and Payload CMS codebase from the previous caching model to Cache Components. The migration eventually passed both npx tsc --noEmit and pnpm build, but the difficult parts were not the obvious API replacements.

Changing unstable_cache() into a function containing 'use cache' is straightforward. The real work is deciding which reads should prerender, which should use a shared cache, which should remain request-time, and where Suspense boundaries belong. Production builds also expose problems that are easy to miss in development: old cache keys becoming invalid cache tags, Redis clients connecting during module import, custom cacheLife() profiles fighting strict TypeScript, and third-party routes such as the Payload admin requiring their own treatment.

This guide combines the official Next.js 16 model with the practical lessons from completing that migration. It covers the full path from enabling cacheComponents to validating the production build, including the failure modes I would now check before changing the first data function.

The short version

Cache Components is not a new spelling for unstable_cache. It changes the rendering model.

With cacheComponents: true:

  • Data access is uncached by default.
  • You opt specific functions, components, pages, or layouts into caching with 'use cache'.
  • Next.js prerenders the largest static shell it can.
  • Request-time or deliberately fresh work streams through a nearby <Suspense> boundary.
  • cacheLife() defines freshness and expiry.
  • cacheTag() connects cached output to on-demand invalidation.
  • updateTag() provides immediate read-your-own-writes behavior in Server Actions.
  • revalidateTag(tag, 'max') provides stale-while-revalidate behavior, including from CMS webhooks.
  • connection() is a narrow request-time marker, not a general fix for dynamic pages.

The correct migration is therefore:

  1. Classify every read by freshness, scope, and sensitivity.
  2. Cache reusable public data near its source.
  3. Stream genuinely request-time work through small Suspense boundaries.
  4. Preserve the static page shell.
  5. Design cache keys and tags around tenant, locale, collection, and document identity.
  6. Verify persistence requirements before replacing unstable_cache.

The most important operational warning is this:

unstable_cache uses Next.js's persistent Data Cache. Plain 'use cache' uses in-memory storage at runtime by default. On serverless infrastructure, those entries may not survive across requests or instances. Use 'use cache: remote', a custom cache handler, an existing application cache, or temporarily retain unstable_cache where durable cross-instance caching is required.

Next.js explicitly supports an incremental migration. Existing fetch caching and unstable_cache continue to work as a separate layer after Cache Components is enabled. There is no need for a risky big-bang rewrite.

Official references: migration guide, use cache, and cacheComponents.


1. The new mental model

The previous question

Under the previous caching model, teams often asked:

Is this route static or dynamic?

That encouraged route-wide decisions such as:

ts
export const dynamic = 'force-static'
export const revalidate = 3600

or:

ts
export const dynamic = 'force-dynamic'

The Cache Components question

Now ask this for every subtree:

Which output can be reused, and which output requires the current request?

A route can contain all of the following at once:

  • Static JSX included in the prerendered shell
  • Cached CMS or database content included in the shell
  • Request-time content streamed later under <Suspense>
  • Client interactivity hydrated after the HTML arrives

This is Partial Prerendering as the default App Router behavior. The old experimental.ppr settings are removed.

The four rendering buckets

BucketTypical examplesCorrect treatment
Pure staticNavigation structure, fixed copy, iconsDo nothing
Shared cacheable dataPublished CMS pages, products, recipes, site settings'use cache' plus cacheLife() and cacheTag()
Shared but durability-sensitive dataExpensive CMS/DB reads used at request time across serverless instances'use cache: remote', custom handler, existing cache, or retain unstable_cache temporarily
Request-specific or always-freshSession, cookies, authorization, live per-request readSmall async component under <Suspense>

The static shell is the default goal. Dynamic work is an island inside it.


2. What changes when cacheComponents is enabled

Enable the feature in next.config.ts:

ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Then remove obsolete configuration as each route is migrated:

Previous mechanismCache Components treatment
dynamic = 'force-dynamic'Remove it. Data is uncached by default. Add narrow Suspense boundaries for request-time work.
dynamic = 'force-static'Remove it. Add 'use cache' only around data or output that should be cached.
revalidate = nReplace with cacheLife() inside a cached scope.
fetchCacheRemove it. Fetches inside a cached scope are part of that cached computation.
fetch(..., { cache: 'force-cache' })Move the fetch into a 'use cache' function.
fetch(..., { next: { revalidate, tags } })Replace with cacheLife() and cacheTag() inside the cached function.
unstable_cache()Convert the wrapped callback into an async function containing 'use cache'.
unstable_noStore()Remove it. Work is uncached by default. Use connection() only when a request-time marker is truly required.
experimental.ppr / experimental_pprRemove. PPR is part of Cache Components.
runtime = 'edge'Remove or move the route. Cache Components requires the Node.js runtime.

Do not delete all old caching in one mechanical pass. Enable Cache Components, let development and production builds identify uncached work, then migrate one coherent data path at a time.


3. The key APIs and what each one means

'use cache'

Add the directive as the first statement in an async function or async component:

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

export async function getArticle(slug: string) {
  'use cache'

  cacheLife('days')
  cacheTag('articles', `article:${slug}`)

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

The cache key automatically includes:

  1. The build ID
  2. A function ID derived from the function location and signature
  3. Serializable arguments
  4. Captured outer-scope values
  5. A development-only HMR refresh hash

This is why the old keyParts array usually disappears.

cacheLife()

cacheLife() controls three different clocks:

PropertyMeaning
staleHow long the browser router can reuse its cached result without checking the server
revalidateWhen the next server request can serve stale output and start background regeneration
expireAfter this much inactivity, the next request must wait for fresh output

Built-in profiles in Next.js 16.2.10:

ProfilestalerevalidateexpireGood fit
default5 min15 minNeverGeneral content
seconds30 sec1 sec1 minNear-real-time shared data
minutes5 min1 min1 hourFrequently updated content
hours5 min1 hour1 dayInventory, weather, regular updates
days5 min1 day1 weekArticles and editorial pages
weeks5 min1 week30 daysWeekly publishing
max5 min30 days1 yearRarely changing content

For explicit business semantics, define named profiles:

ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    cms: {
      stale: 300,
      revalidate: 3600,
      expire: 86400,
    },
    catalog: {
      stale: 300,
      revalidate: 900,
      expire: 86400,
    },
  },
}

export default nextConfig

Use one cacheLife() call per executed function path. Omitted values inherit from the default profile, so write all three values when you want the profile to be unambiguous.

When strict TypeScript rejects custom profile names

The named profile approach is clean conceptually, but strict TypeScript did not accept custom names such as cacheLife('cms') cleanly in the codebase I migrated. Do not spend hours fighting generated or augmented types for something that can remain explicit.

A practical alternative is to centralize the profile objects:

ts
// src/lib/cache/cache-life.ts
export const CACHE_LIFE = {
  cms: {
    stale: 300,
    revalidate: 3600,
    expire: 86400,
  },
  catalog: {
    stale: 300,
    revalidate: 900,
    expire: 86400,
  },
} as const
ts
import { cacheLife } from 'next/cache'
import { CACHE_LIFE } from '@/lib/cache/cache-life'

export async function getPage(slug: string) {
  'use cache'

  cacheLife(CACHE_LIFE.cms)
  // ...
}

This keeps the values centralized, typed, and easy to review without relying on custom string names at every call site.

Official reference: cacheLife.

cacheTag()

Tags are invalidation indexes, not cache keys.

ts
cacheTag(
  `tenant:${tenantSlug}`,
  `tenant:${tenantSlug}:pages`,
  `tenant:${tenantSlug}:page:${pageSlug}`,
)

A cache entry can have several tags. A single call accepts up to 128 tags, and each tag is limited to 256 characters. Tags are case-sensitive.

This distinction deserves a hard warning during migration:

Never mechanically move old keyParts, CACHE_KEY.*() values, serialized queries, or arbitrary filters into cacheTag().

Old cache keys often encode every variable that makes a query unique. Tags solve a different problem. They identify stable groups that should be invalidated together.

ts
// Bad: an old key-style value may be long, unstable, and invalid as a tag
cacheTag(
  CACHE_KEY.PRODUCTS(tenantSlug, JSON.stringify(query)),
)
ts
// Better: short, stable invalidation scopes
cacheTag(
  `tenant:${tenantSlug}`,
  `tenant:${tenantSlug}:products`,
)

If the query filters affect the returned value, they belong in the cached function arguments so they participate in the cache key. They do not automatically belong in the invalidation tags.

Centralize tag construction when possible:

ts
export function createCacheTag(...parts: string[]) {
  const tag = parts
    .map((part) => part.trim().toLowerCase())
    .filter(Boolean)
    .join(':')

  if (!tag) {
    throw new Error('Cache tag cannot be empty')
  }

  if (tag.length > 256) {
    throw new Error(`Cache tag exceeds 256 characters: ${tag}`)
  }

  return tag
}

Official reference: cacheTag.

updateTag()

Use updateTag() only in a Server Action after a mutation when the same user must see fresh data immediately:

ts
'use server'

import { updateTag } from 'next/cache'

export async function updatePage(input: UpdatePageInput) {
  await db.pages.update({
    where: { id: input.id },
    data: input.data,
  })

  updateTag(`tenant:${input.tenantSlug}:page:${input.slug}`)
  updateTag(`tenant:${input.tenantSlug}:pages`)
}

The next read waits for fresh data. updateTag() cannot be called from Route Handlers, Client Components, or webhooks.

Official reference: updateTag.

revalidateTag()

Use revalidateTag(tag, 'max') when stale-while-revalidate is acceptable, especially from CMS webhooks:

ts
import { revalidateTag } from 'next/cache'

export async function POST(request: Request) {
  const event = await verifyAndParseCmsWebhook(request)

  revalidateTag(
    `tenant:${event.tenantSlug}:${event.collection}:${event.slug}`,
    'max',
  )

  return Response.json({ revalidated: true })
}

The one-argument form is deprecated. Always pass 'max' or a custom profile. Revalidation is lazy: it marks matching entries stale, and refresh begins when an affected resource is next visited.

Official reference: revalidateTag.

connection()

connection() says that code below this point must wait for an actual request. It is mainly for request-varying work that does not already use cookies(), headers(), params, or searchParams, such as Math.random(), new Date(), or a synchronous database driver.

tsx
import { connection } from 'next/server'

export async function RequestTimestamp() {
  await connection()
  return <time>{new Date().toISOString()}</time>
}

It is not a general declaration that a page is dynamic. Place it in the smallest component requiring it, and render that component under <Suspense>.

Official reference: connection.


4. The direct unstable_cache migration pattern

Before

ts
import { unstable_cache } from 'next/cache'

export const getUser = unstable_cache(
  async (id: string) => {
    return db.users.findUnique({ where: { id } })
  },
  ['user'],
  {
    revalidate: 3600,
    tags: ['users'],
  },
)

After

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

export async function getUser(id: string) {
  'use cache'

  cacheLife('hours')
  cacheTag('users', `user:${id}`)

  return db.users.findUnique({ where: { id } })
}

What changed

  • The function arguments form the variable part of the cache key.
  • cacheLife() replaces revalidate.
  • cacheTag() replaces tags.
  • Dynamic tags can now naturally use arguments or returned data.
  • The function and its result must follow React Server Component serialization rules.
  • Runtime persistence is different, so you must separately choose local memory, remote cache, custom storage, or a retained old layer.

Do not preserve a redundant manual key prefix

This is usually unnecessary:

ts
export async function getUser(id: string) {
  'use cache'
  return db.users.findUnique({ where: { id } })
}

The function identity already distinguishes getUser() from other cached functions, and id is already included.

Use tags for invalidation. Do not try to recreate keyParts with tags.


5. The persistence decision you must make

This is the part most mechanical migrations miss.

Plain 'use cache'

  • Includes cacheable output in the prerendered shell.
  • Uses an in-memory LRU at runtime.
  • Works well on a persistent self-hosted Node process.
  • May have poor cross-request hit rates on serverless instances.
  • Does not normally persist across deployment boundaries.

'use cache: remote'

  • Stores output in a provider-backed or custom remote handler.
  • Shares entries across server instances.
  • Adds network latency and infrastructure cost.
  • Is appropriate for expensive, slow, rate-limited, or fragile upstream services.
  • Is a poor choice for high-cardinality filters or very fast local operations.
ts
import { cacheLife, cacheTag } from 'next/cache'

export async function getPublishedProducts(
  tenantSlug: string,
  locale: string,
) {
  'use cache: remote'

  cacheLife('catalog')
  cacheTag(
    `tenant:${tenantSlug}`,
    `tenant:${tenantSlug}:products`,
    `tenant:${tenantSlug}:locale:${locale}:products`,
  )

  return queryPublishedProducts({ tenantSlug, locale })
}

Never place user-specific or secret-bearing results in a remote shared cache. The experimental 'use cache: private' directive is not recommended for production in Next.js 16.2.10. For authenticated data, prefer an uncached request-time component under <Suspense>.

Official reference: use cache: remote.

Migration choice matrix

SituationRecommended choice
Published CMS data rendered into a static shellPlain 'use cache'
Same CMS read happens at request time across many serverless instances'use cache: remote' if the provider supplies a handler
Existing Redis or application cache already wraps the repositoryKeep that layer, add plain 'use cache' only for rendering semantics
Query is below roughly 50 ms and origin capacity is healthyPlain 'use cache' or no server runtime cache
Query is expensive, rate-limited, or fragileRemote cache or existing durable application cache
User-specific dashboardUncached component under <Suspense>
Transition has uncertain infrastructure supportRetain unstable_cache temporarily and migrate rendering first

6. Tenant-safe and locale-safe caching

In a multi-tenant system, every value that changes the returned data must affect the cache key.

Prefer explicit primitive arguments:

ts
export async function getPage(
  tenantSlug: string,
  locale: string,
  pageSlug: string,
) {
  'use cache'

  cacheLife('cms')
  cacheTag(
    `tenant:${tenantSlug}`,
    `tenant:${tenantSlug}:pages`,
    `tenant:${tenantSlug}:locale:${locale}:pages`,
    `tenant:${tenantSlug}:locale:${locale}:page:${pageSlug}`,
  )

  return queryPage({ tenantSlug, locale, pageSlug })
}

Do not rely on ambient tenant context:

ts
// Dangerous: tenant identity is invisible at the call site.
export async function getPage(pageSlug: string) {
  'use cache'
  const tenant = getGlobalTenantContext()
  return queryPage({ tenant, pageSlug })
}

Although captured outer variables can become part of a cache key, explicit arguments are easier to audit, test, and invalidate.

Recommended tag hierarchy

For a document entry, add tags from broad to narrow:

text
tenant:canprev
tenant:canprev:pages
tenant:canprev:locale:en:pages
tenant:canprev:locale:en:page:about-us

This lets a webhook invalidate:

  • One document
  • One locale's collection
  • An entire collection for a tenant
  • Everything for a tenant

Do not use a global tag such as pages unless publishing one tenant should invalidate every tenant.

Cache the resolved locale, not request machinery

cookies(), headers(), and request-locale helpers cannot run inside a normal cached function. Resolve the locale outside, then pass a plain string into the cached function.

For routes where the locale is already a static segment, use the segment value or a static locale loader. Do not call a request-dependent getMessages() from a supposedly static root layout if it awaits request locale internally.


7. Suspense architecture for dynamic work

Suspense is not a cache and it does not make a component dynamic. It defines what React should render while a descendant is waiting for asynchronous work.

With Cache Components enabled, Next.js uses that boundary to split a route into two parts:

  1. The static shell that can be produced ahead of the request
  2. The request-time content that will stream later

Suppose a page contains a static hero, an uncached product query, and a static footer:

tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      <Hero />

      <Suspense fallback={<ProductGridSkeleton />}>
        <ProductGrid />
      </Suspense>

      <Footer />
    </main>
  )
}

async function ProductGrid() {
  const products = await db.products.findMany()
  return <Products products={products} />
}

The rendering sequence is:

  1. Next.js prerenders the hero, product skeleton, and footer.
  2. The browser receives and displays that shell immediately.
  3. At request time, the server executes ProductGrid().
  4. React streams the rendered Server Component output when the query resolves.
  5. React replaces only ProductGridSkeleton with the real grid.

This is not necessarily a second browser API request. The server can stream the React Server Component result through the route response.

If ProductGrid() instead calls a 'use cache' data function, Next.js knows the work is reusable and can normally include the completed product grid in the prerendered shell. That is the practical relationship:

TreatmentRendering behavior
Synchronous static UIIncluded in the static shell
Data behind 'use cache'Can be computed and included in the static shell
Uncached asynchronous dataFallback in the shell, real content streamed at request time
cookies(), headers(), or request parametersRequest-time content below Suspense
connection()Explicitly waits for a request before continuing

loading.tsx versus manual Suspense

A loading.tsx file creates an automatic Suspense boundary around the page and child segments beneath it:

text
app/
  products/
    layout.tsx
    loading.tsx
    page.tsx

Conceptually, Next.js treats this like:

tsx
<ProductsLayout>
  <Suspense fallback={<Loading />}>
    <ProductsPage />
  </Suspense>
</ProductsLayout>

Use loading.tsx when most of a route segment waits for the same request-time work. It also gives Next.js a loading state it can prefetch, so navigation can begin immediately while the page streams.

Use manual Suspense boundaries when only particular sections are dynamic or when independent sections should appear as soon as each one resolves:

tsx
export default function ProductPage() {
  return (
    <>
      <StaticProductHeading />

      <Suspense fallback={<DetailsSkeleton />}>
        <ProductDetails />
      </Suspense>

      <Suspense fallback={<ReviewsSkeleton />}>
        <ProductReviews />
      </Suspense>

      <Suspense fallback={<RecommendationsSkeleton />}>
        <Recommendations />
      </Suspense>
    </>
  )
}

The important design choice is boundary size. A route-level fallback is simple, but it can hide content that could have appeared immediately. Smaller boundaries preserve more of the static shell and prevent a slow secondary service from delaying the primary content.

If content visibly jumps into place, inspect the fallback. fallback={null} reserves no space, so everything below the boundary moves when the real component arrives. A skeleton should approximate the final component's dimensions to reduce layout shift.

Wrong: block the whole page

tsx
import { connection } from 'next/server'

export default async function Page() {
  await connection()
  const account = await getCurrentAccount()

  return (
    <main>
      <MarketingHero />
      <AccountPanel account={account} />
    </main>
  )
}

This prevents the marketing shell from being prerendered.

Right: isolate the request-time island

tsx
import { Suspense } from 'react'

export default function Page() {
  return (
    <main>
      <MarketingHero />

      <Suspense fallback={<AccountPanelSkeleton />}>
        <CurrentAccountPanel />
      </Suspense>
    </main>
  )
}

async function CurrentAccountPanel() {
  const account = await getCurrentAccount()
  return <AccountPanel account={account} />
}

If getCurrentAccount() already calls cookies() or headers(), do not also add connection(). The request API already establishes request-time behavior.

Fresh database reads

An uncached asynchronous database call is treated as request-time work. Put it in the nested async component under Suspense:

tsx
export default function InventoryPage() {
  return (
    <>
      <InventoryHeading />
      <Suspense fallback={<LiveInventorySkeleton />}>
        <LiveInventory />
      </Suspense>
    </>
  )
}

async function LiveInventory() {
  const inventory = await db.inventory.findMany()
  return <InventoryTable inventory={inventory} />
}

connection() is only needed when Next.js cannot otherwise see that the work must wait for a request, such as a synchronous driver or time/randomness.

Boundary placement rule

Place <Suspense> immediately above the smallest subtree that:

  • Reads cookies() or headers()
  • Awaits request-dependent params or searchParams
  • Calls connection()
  • Performs intentionally uncached asynchronous I/O

Do not put a boundary at the root merely to silence the error. A boundary that covers most of the page sacrifices most of the static shell.

Fallback rule

A fallback must be independent, cheap, and deterministic:

tsx
<Suspense fallback={<ProductGridSkeleton rows={3} />}>
  <PersonalizedProductGrid />
</Suspense>

Never render {children}, the same dynamic component, or another request-dependent tree inside the fallback.


8. Dynamic params and searchParams

In Next.js 16, page params and searchParams are promises. Awaiting them at the top of a page can make the whole route block.

Pass the promise through to the narrow dynamic component:

tsx
import { Suspense } from 'react'

export default function SearchPage({
  searchParams,
}: PageProps<'/search'>) {
  return (
    <main>
      <SearchHeading />
      <Suspense fallback={<SearchResultsSkeleton />}>
        <SearchResults searchParams={searchParams} />
      </Suspense>
    </main>
  )
}

async function SearchResults({
  searchParams,
}: Pick<PageProps<'/search'>, 'searchParams'>) {
  const { query = '' } = await searchParams
  const results = await search(query)
  return <ResultsList results={results} />
}

For a dynamic segment, generateStaticParams() can establish known build-time paths. With Cache Components enabled, it must return at least one parameter object. Returning [] is an error.

If the entire route depends on params and there is no useful static shell, a segment-level loading.tsx can be simpler than a manually placed boundary.


9. Layouts and internationalization

Layouts have a larger blast radius than pages. A request-time read in a root or locale layout can make every descendant depend on that request boundary.

Rules

  1. Never add connection() to a layout until you have proved every child route streams correctly.
  2. Keep root layout concerns static where possible.
  3. Resolve fixed locale messages from a static input, not from request state.
  4. Move session controls, region banners, or personalized navigation into small Suspense-wrapped children.
  5. Cache shared navigation or settings by tenant and locale.
tsx
export default function LocaleLayout({
  children,
  params,
}: LayoutProps<'/[locale]'>) {
  return (
    <>
      <Suspense fallback={<HeaderSkeleton />}>
        <LocalizedHeader params={params} />
      </Suspense>
      {children}
    </>
  )
}

If generateStaticParams() supplies all supported locales, the locale layout may be able to remain fully prerenderable. Audit the actual i18n request loader because a helper named getMessages() can conceal a requestLocale, headers(), or cookies dependency.


10. Metadata, route handlers, and other special surfaces

generateMetadata() and generateViewport()

If metadata comes from shared CMS or database data, cache the function. The example below assumes the slug is known through generateStaticParams() and can participate in prerendering:

ts
export async function generateMetadata({ params }: PageProps<'/[slug]'>) {
  'use cache'

  const { slug } = await params
  const page = await getPageMetadata(slug)

  return {
    title: page.title,
    description: page.description,
  }
}

If metadata genuinely requires request-time data, it cannot be wrapped in Suspense directly. Next.js documents a small Suspense-wrapped dynamic marker component in the page to make that intent explicit. Treat dynamic metadata as an exception because it complicates the route model.

GET Route Handlers

Do not put 'use cache' on the exported GET handler. Cache a helper instead:

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

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

async function getProducts() {
  'use cache'

  cacheLife('catalog')
  cacheTag('products')

  return db.products.findMany()
}

Be careful with broad try/catch blocks. During prerendering, Next.js can use a thrown internal signal to bail out of a GET handler. Existing catch blocks may log this as if it were an application error.

For a GET handler that must remain request-time, connection() can make that intent explicit. Perform cheap synchronous validation before opening Redis, initializing an SDK, or making an external request:

ts
import { connection } from 'next/server'

export async function GET(request: Request) {
  const url = new URL(request.url)
  const approvalCode = url.searchParams.get('code')

  if (!approvalCode) {
    return Response.json(
      { error: 'Missing approval code' },
      { status: 400 },
    )
  }

  await connection()

  const redis = getRedis()
  const approval = await redis.get(`approval:${approvalCode}`)

  return Response.json({ approval })
}

This does not replace authentication or authorization. It simply avoids initializing request-only infrastructure for obviously invalid requests.

Draft Mode

When Draft Mode is enabled, cached functions execute fresh and their results are not stored. This generally means published and preview paths can share the same cached data functions.

draftMode() is a special supported read inside a normal 'use cache' scope:

ts
import { draftMode } from 'next/headers'

export async function getPage(slug: string) {
  'use cache'

  const { isEnabled } = await draftMode()

  return payload.find({
    collection: 'pages',
    draft: isEnabled,
    where: {
      slug: { equals: slug },
    },
  })
}

When Draft Mode is off, the published result can be cached and prerendered. When Draft Mode is on, Next.js re-executes the scope and does not save the result.

A common mistake is reading draftMode() in an otherwise uncached page root and then passing isDraft through the entire route. That makes the page root request-dependent even for ordinary published traffic. Keep the Draft Mode decision inside the cached data boundary where practical. Other runtime APIs such as cookies() and headers() are still forbidden inside a normal 'use cache' scope.

Node runtime

Cache Components does not support runtime = 'edge' in Next.js 16.2.10. Remove the export and use the default Node.js runtime. If edge request handling is necessary, keep it in Proxy or outside Cache Components routes.


11. Build-time execution and module-scope side effects

Cache Components makes the production build a much more active participant in your application. Next.js imports route modules and executes cacheable work while constructing static shells. Code that appeared harmless during development can therefore connect to external infrastructure during next build.

Audit module scope for:

  • new Redis(...)
  • Database client construction
  • External SDK clients that authenticate or open connections
  • Tenant or request resolution
  • Date.now(), new Date(), and Math.random() used to create exported values
  • Top-level promises or asynchronous initialization

Avoid this:

ts
import Redis from 'ioredis'

const redis = new Redis(process.env.REDIS_URL!)

export async function getApproval(id: string) {
  return redis.get(`approval:${id}`)
}

The client is created as soon as the module is imported. That can produce authentication failures, network timeouts, or unwanted external connections during the build.

Lazy construction prevents import-time connections:

ts
import Redis from 'ioredis'

let redis: Redis | undefined

export function getRedis() {
  if (!redis) {
    redis = new Redis(process.env.REDIS_URL!)
  }

  return redis
}

But lazy construction does not automatically mean request-time construction. If getRedis() is called inside a cached function while Next.js is prerendering, it can still connect during the build.

Keep these three execution moments separate:

Execution momentWhat it meansRule
Module importFile is loaded by the build or runtimeDo not open connections or resolve request state
PrerenderNext.js executes cacheable work to build the static shellRequired services must be available during the build
Request timeA real request has reached the dynamic subtreeInitialize request-only clients after the request boundary

For request-only Redis work:

tsx
import { connection } from 'next/server'

export async function ApprovalStatus({ id }: { id: string }) {
  await connection()

  const redis = getRedis()
  const approval = await redis.get(`approval:${id}`)

  return <Status value={approval} />
}

Render that component below Suspense. If Redis supplies public data that you intentionally want in the static shell, then Redis must instead be available during prerendering.


12. Payload CMS reference architecture

Use a thin cached repository layer. Keep request resolution, authorization, and preview decisions outside it.

ts
// src/data/pages/get-published-page.ts
import { cacheLife, cacheTag } from 'next/cache'
import { getPayload } from 'payload'
import config from '@payload-config'

export async function getPublishedPage(input: {
  tenantSlug: string
  locale: string
  pageSlug: string
}) {
  'use cache'

  const { tenantSlug, locale, pageSlug } = input

  cacheLife('cms')
  cacheTag(
    `tenant:${tenantSlug}`,
    `tenant:${tenantSlug}:pages`,
    `tenant:${tenantSlug}:locale:${locale}:pages`,
    `tenant:${tenantSlug}:locale:${locale}:page:${pageSlug}`,
  )

  const payload = await getPayload({ config })

  const result = await payload.find({
    collection: 'pages',
    locale,
    limit: 1,
    pagination: false,
    depth: 2,
    where: {
      and: [
        { slug: { equals: pageSlug } },
        { 'tenant.slug': { equals: tenantSlug } },
        { _status: { equals: 'published' } },
      ],
    },
  })

  return result.docs[0] ?? null
}

Then keep the page shell simple:

tsx
import { notFound } from 'next/navigation'
import { getPublishedPage } from '@/data/pages/get-published-page'

export default async function Page({ params }: PageProps<'/[tenant]/[locale]/[slug]'>) {
  const { tenant, locale, slug } = await params
  const page = await getPublishedPage({
    tenantSlug: tenant,
    locale,
    pageSlug: slug,
  })

  if (!page) notFound()

  return <RenderBlocks blocks={page.layout} />
}

For routes not covered by generateStaticParams(), move the params await and page read into a Suspense-wrapped child.

Payload admin and other vendor-provided routes

Do not assume that a third-party App Router surface can be migrated by adding connection() around it. The Payload admin route was one of the areas that needed separate treatment in the production migration.

The working changes were:

  • Stop generating request-dependent metadata for the admin route when static metadata was sufficient.
  • Render Payload's RootPage as JSX so React and Next.js can manage the component boundary correctly.
  • Add an admin loading.tsx boundary.
  • Keep broad connection() calls out of the surrounding layout.

Avoid calling a React component as a normal function:

tsx
export default function AdminPage(props: AdminPageProps) {
  return RootPage({
    config,
    importMap,
    ...props,
  })
}

Render it as JSX:

tsx
export default function AdminPage(props: AdminPageProps) {
  return (
    <RootPage
      config={config}
      importMap={importMap}
      {...props}
    />
  )
}

Then give the segment a real loading boundary:

tsx
// app/(payload)/admin/[[...segments]]/loading.tsx
export default function Loading() {
  return <AdminLoadingScreen />
}

The exact generated files can change between Payload releases, so treat this as a verification pattern rather than a patch to copy blindly. Check the current Payload-generated route, preserve its expected props and imports, and run the full production build after every change.

Payload webhook invalidation

A publish event should send stable identifiers, not only an arbitrary URL:

ts
type CmsInvalidationEvent = {
  tenantSlug: string
  locale: string
  collection: 'pages' | 'posts' | 'recipes' | 'products'
  slug: string
  previousSlug?: string
}

Invalidate both the item and affected lists:

ts
revalidateTag(
  `tenant:${tenantSlug}:locale:${locale}:${collection}:${slug}`,
  'max',
)

revalidateTag(
  `tenant:${tenantSlug}:locale:${locale}:${collection}`,
  'max',
)

If a slug changes, invalidate both the previous and current document tags. Also update redirects or path-level caches as required.

Do not let a webhook accept arbitrary tag strings from the public request body. Verify the webhook signature and construct allowed tags on the server.


13. Cache key and tag design rules

Cache key rules

  1. Every value that changes returned data must be an argument or captured serializable value.
  2. Prefer stable primitives over large configuration objects.
  3. Include tenant, locale, publication state, permissions scope, pagination, and filters when they affect results.
  4. Normalize equivalent values before the cached call.
  5. Avoid high-cardinality request values unless cache reuse justifies them.
  6. Never include secrets merely to make a cache key unique.

Tag rules

  1. Tags express invalidation groups.
  2. Use a consistent namespace.
  3. Add both collection and document tags when a mutation affects detail and listing pages.
  4. Include tenant and locale before collection identity.
  5. Keep tags short enough to stay below 256 characters.
  6. Do not invalidate globally when a narrower tag is available.

High-cardinality example

Bad:

ts
getProducts({
  tenant,
  locale,
  minPrice,
  maxPrice,
  search,
  sort,
  page,
})

If every combination becomes a remote cache entry, hit rates may approach zero.

Better:

ts
const products = await getProductsByCategory(tenant, locale, category)
const filtered = applyCheapFilters(products, { minPrice, maxPrice, sort })

Only do this when returning the broader dataset is safe and bounded. Large product catalogs may require a different query strategy.


14. Security and personalization

Cache only data that is safe to reuse for the cache's audience.

Never remotely share

  • Session-bearing responses
  • Authorization decisions
  • Private account details
  • User-specific recommendations derived from sensitive history
  • Draft content without a correctly isolated preview mechanism
  • Request headers or cookies as returned data

Preferred authenticated pattern

tsx
export default function DashboardPage() {
  return (
    <DashboardShell>
      <Suspense fallback={<DashboardSkeleton />}>
        <AuthenticatedDashboard />
      </Suspense>
    </DashboardShell>
  )
}

async function AuthenticatedDashboard() {
  const session = await requireSession()
  const data = await getFreshDashboardData(session.user.id)
  return <Dashboard data={data} />
}

Reading a cookie outside a cached function and passing the raw token into that function may technically produce a different key per token, but it stores sensitive, low-reuse output in a server cache. That is usually the wrong architecture.


15. Client Components and preserved navigation state

'use client' does not make initial rendering nondeterministic code safe.

Avoid this:

tsx
'use client'

export function Timer() {
  const [startedAt] = useState(Date.now())
  return <span>{startedAt}</span>
}

Prefer a deterministic initial value and set request/browser time after mount, or pass a server-provided stable value when appropriate.

Cache Components also makes Next.js preserve recently visited routes using React <Activity>. Navigating away hides a route instead of immediately unmounting it. State such as form values, expanded sections, and scroll position can survive when navigating back.

Audit UI that previously depended on unmounting:

  • Dialogs and dropdowns
  • Form success and error states
  • useActionState
  • Focus initialization
  • Cleanup effects
  • Tests that assert unmounting on navigation

Add explicit reset behavior where the product requires it.


16. A safe migration plan

Phase 0: establish a baseline

Before enabling Cache Components:

bash
pnpm lint
pnpm typecheck
pnpm test
pnpm build

Record:

  • Build output and route behavior
  • Critical page response times
  • Origin query volume
  • Existing cache hit metrics
  • Webhook invalidation behavior
  • Preview and Draft Mode behavior
  • Authenticated route behavior

Phase 1: inventory the old model

Search the codebase:

bash
rg -n "unstable_cache|unstable_noStore|force-static|force-dynamic|fetchCache|revalidate\s*=|experimental_ppr|runtime\s*=\s*['\"]edge['\"]" src app
rg -n "cookies\(|headers\(|connection\(|searchParams|requestLocale|getMessages\(" src app
rg -n "Date\.now\(|Math\.random\(|new Date\(" src app
rg -n "new Redis\(|new PrismaClient\(|new [A-Za-z]+Client\(|^[[:space:]]*const .*Promise" src app

For each result, record:

ItemRoute/data functionCurrent keyCurrent tagsTTLTenant-awareLocale-awareDurable cache requiredTarget treatment

Phase 2: classify every read

For each data access, answer:

  1. Can this result be shared between users?
  2. Must it be fresh on every request?
  3. What arguments change the result?
  4. Which mutation should invalidate it?
  5. Is stale-while-revalidate acceptable?
  6. Must the cache persist across server instances?
  7. Is the result serializable?
  8. Can the origin tolerate cache misses after deployment?

Also identify vendor-owned or generated routes, such as the Payload admin, authentication callbacks, monitoring endpoints, and third-party dashboards. Track these separately instead of assuming the frontend migration pattern will apply unchanged.

Phase 3: enable the flag without rewriting everything

Enable cacheComponents: true. Keep existing unstable_cache and cached fetches temporarily. Run development and production builds to expose uncached work and request-time boundaries.

Do not respond to every error by inserting connection().

Phase 4: migrate shared public data

Start with low-risk, high-reuse reads:

  • Site settings
  • Published pages
  • Navigation
  • Article and recipe detail
  • Product taxonomy
  • Public product detail

For each function:

  1. Convert the wrapper to an async function.
  2. Add 'use cache'.
  3. Replace TTL with cacheLife().
  4. Replace tags with cacheTag().
  5. Make tenant and locale explicit arguments.
  6. Test detail and list invalidation.
  7. Decide whether memory persistence is sufficient.

Do not migrate old keyParts or CACHE_KEY.*() output into tags. Build tags from stable invalidation scopes and keep query state in the function arguments.

Phase 5: isolate request-time work

Move session, request locale, previews, live reads, and high-cardinality search into narrow async components. Add <Suspense> at the same time, with an independent fallback.

Phase 6: migrate layouts and special surfaces

Only after page-level patterns work:

  • Root and locale layouts
  • generateMetadata()
  • generateViewport()
  • GET Route Handlers
  • Dynamic routes and generateStaticParams()
  • Draft Mode
  • Sitemap and robots generation

Phase 7: choose durable cache infrastructure

Compare production telemetry before and after conversion. If origin calls rise on serverless hosting:

  • Use 'use cache: remote' for high-value shared reads
  • Configure a custom handler when self-hosting
  • Keep an existing Redis/application cache
  • Retain unstable_cache for specific functions until the replacement is proven

Phase 8: remove compatibility code

Only remove the final unstable_cache uses after:

  • Equivalent freshness is demonstrated
  • Cross-instance behavior is understood
  • Webhooks invalidate the right scopes
  • Origin load stays within capacity
  • Rollback has been tested

17. Verification and debugging

Always run a production build

Mechanical changes can create valid-looking but broken code, including imports inserted inside other imports or await connection() inserted into parameter lists.

Run:

bash
pnpm build

When a blocking route error names only the route:

bash
pnpm next build --debug-prerender

This produces prerender stack traces that point to the actual component or helper, such as an i18n request loader hidden behind getMessages().

For verbose cache behavior:

bash
NEXT_PRIVATE_DEBUG_CACHE=1 pnpm dev
NEXT_PRIVATE_DEBUG_CACHE=1 pnpm start

Verification matrix

Test each migrated route in these states:

ScenarioExpected result
Cold production requestCorrect shell and streamed regions
Warm requestCached shared data reused according to the chosen storage
Client navigationStatic shell appears immediately where designed
CMS publish webhookMatching item and list entries become stale
Server Action mutationUser sees the update immediately after updateTag()
Another tenantNever receives the first tenant's content
Another localeReceives the correct localized content
Draft ModeFresh preview content, no cache persistence
Authenticated userNo private data in shared cache
New deploymentExpected cold-cache behavior and acceptable origin load
Navigation away and backPreserved client state behaves intentionally

Prove actual reuse

During migration, instrument the underlying repository call, not only the page render:

ts
export async function queryPage(input: QueryPageInput) {
  console.info('origin:queryPage', {
    tenantSlug: input.tenantSlug,
    locale: input.locale,
    pageSlug: input.pageSlug,
  })

  return payload.find(/* ... */)
}

Verify the number of origin calls across:

  • Repeated requests
  • Separate pages using the same function
  • Different arguments
  • Different server instances where observable
  • Tag invalidation
  • Deployment rollover

Remove noisy diagnostic logs after verification.


18. Common failures and their real fixes

Failure: Uncached data was accessed outside of <Suspense>

Decide intent first:

  • Shared reusable data: add 'use cache' close to the data source.
  • Request-time data: move it into a child under <Suspense>.
  • Request-only randomness or synchronous I/O: add connection() inside that child.

Failure: blocking-route

Look for:

  • Top-level awaits of params or searchParams
  • cookies() or headers() in pages and layouts
  • Uncached async I/O
  • connection() above the useful static shell
  • Hidden runtime reads inside i18n, auth, or CMS helpers

Use next build --debug-prerender when the normal build lacks a useful stack.

Failure: every route under a layout blocks

Remove layout-level connection() or request reads. Move the dynamic part to a small child and wrap that child in Suspense.

Failure: Suspense does not improve anything

The request-time read probably occurs above the boundary. Move the read itself, including cookies(), headers(), params, or searchParams, into the child below the boundary.

Failure: fallback recurses or still blocks

The fallback depends on the same dynamic tree. Replace it with a deterministic skeleton that has no children or request data dependency.

Failure: tenant content crosses boundaries

The tenant slug is missing from the cached function arguments, query, or both. Fix the function contract, then invalidate affected broad tags and redeploy if necessary.

Failure: cache hit rate collapses after migration

Plain 'use cache' is using per-instance memory on serverless infrastructure. Evaluate 'use cache: remote', an existing application cache, a custom handler, or temporary retention of unstable_cache.

Failure: invalidation appears delayed

revalidateTag(tag, 'max') is stale-while-revalidate and lazy. The next visit serves stale output while refreshing. Use updateTag() in a Server Action when immediate read-your-own-writes behavior is required.

Failure: cacheLife() throws

It must execute inside a cache directive scope. Put 'use cache' at the top of the containing async function or component.

Failure: cached function cannot read cookies or headers

Resolve shared, non-sensitive values outside the cached scope and pass them as arguments. For private authenticated output, stream it uncached instead of storing it in a shared cache.

Failure: build hangs

Inspect cached functions for unresolved I/O, recursive cached calls, backend connections that behave differently during prerender, or cache handlers that never respond. Use cache debug logging and narrow the build to the affected route where possible.


19. The mistakes-to-avoid checklist

These are hard rules for implementation and review:

  1. Do not sprinkle connection() at page tops.
  2. Do not add connection() to client-shell-only pages such as onboarding.
  3. Run a production build after mechanical edits.
  4. Put uncached work under a Suspense boundary at the same time you introduce it.
  5. Keep static layouts free from implicit request-locale reads.
  6. Treat layout-level connection() as a subtree-wide architectural change.
  7. Make every Suspense fallback independent from the dynamic tree.
  8. Remove render-time randomness from initial Client Component state.
  9. Use next build --debug-prerender for opaque route errors.
  10. Include tenant slug in every tenant-dependent cache key.
  11. Include locale when locale changes returned content.
  12. Do not assume plain 'use cache' preserves unstable_cache durability.
  13. Do not cache private user data in a shared remote cache.
  14. Do not use the deprecated one-argument revalidateTag(tag) form.
  15. Do not put 'use cache' directly on a GET Route Handler export.
  16. Do not return an empty array from generateStaticParams() with Cache Components enabled.
  17. Do not use Cache Components routes on the Edge runtime.
  18. Do not use tags as a substitute for correct cache-key arguments.
  19. Do not move old keyParts or serialized query state into cacheTag().
  20. Do not initialize Redis, database, or external SDK connections at module scope.
  21. Do not assume lazy client construction prevents prerender-time connections.
  22. Check Payload admin and other vendor-owned routes separately.
  23. Validate every generated tag as a non-empty string no longer than 256 characters.

20. Code-review template

Use this on every Cache Components pull request:

Intent

  • Each data read is classified as static, shared cacheable, remote cacheable, or request-time.
  • The route retains the largest useful static shell.
  • connection() appears only where a real request-time marker is needed.

Keys and scope

  • All result-changing inputs are explicit serializable arguments.
  • Tenant slug is included for tenant-dependent reads.
  • Locale is included for localized reads.
  • No private token or secret is used as a shared cache input.

Lifetime and invalidation

  • cacheLife() matches the business freshness requirement.
  • Tags cover both item and affected list views.
  • Server Actions use updateTag() for immediate visibility.
  • Webhooks and Route Handlers use revalidateTag(tag, 'max').
  • Slug changes invalidate both old and new identities.
  • Tags are short invalidation groups, not serialized cache keys.
  • Central tag helpers reject empty or oversized tags.

Rendering

  • Request-time reads occur below a nearby Suspense boundary.
  • Fallbacks are deterministic and independent.
  • No request-dependent read remains in a broad layout without justification.
  • Dynamic metadata is intentional.

Operations

  • Runtime cache persistence has been chosen explicitly.
  • Origin load has been measured.
  • Draft Mode works.
  • Cross-tenant and cross-locale isolation tests pass.
  • Module imports do not open external connections.
  • Required build-time services are available during prerendering.
  • Payload admin and other vendor routes were tested independently.
  • npx tsc --noEmit passes.
  • pnpm build passes.
  • --debug-prerender was used for any opaque blocking route failure.

21. Recommended end state

A mature Cache Components application should have:

  • A small repository layer of clearly named cached functions
  • Explicit tenant and locale arguments
  • Central cache-life profiles based on business semantics
  • Predictable hierarchical tag names
  • CMS webhooks that invalidate narrow tags
  • Server Actions that use updateTag() after mutations
  • Static page and layout shells
  • Small Suspense-wrapped request-time islands
  • No broad layout-level connection() calls
  • A documented choice between memory, remote cache, and existing durable caching
  • Production tests for cache reuse, invalidation, privacy, tenant isolation, and deployment rollover

The goal is not to maximize caching. The goal is to make reuse explicit, request-time work narrow, invalidation predictable, and the initial shell fast.

After completing this migration, the clearest lesson was that Cache Components rewards precise boundaries. The API changes are small. The architectural change is deciding exactly what can be reused and exactly what must wait for a request.

If I were starting the migration again, I would build once much earlier, audit module-scope side effects before touching the data layer, and design the tag namespace before converting any unstable_cache wrapper. Those three steps would have prevented most of the production-specific failures.


Frequently asked questions

Does 'use cache' completely replace unstable_cache?

It is the intended Cache Components replacement, but you do not need to convert everything immediately. Existing unstable_cache calls continue to work as a separate cache layer after cacheComponents is enabled. Keep them temporarily where you have not yet verified persistence, invalidation, or origin-load behavior.

Does Suspense cache its children?

No. Suspense only supplies fallback UI while a descendant waits. A component becomes cacheable through 'use cache'. Uncached asynchronous work below Suspense runs at request time and streams when it resolves.

Should I add connection() to every dynamic page?

No. cookies(), headers(), request parameters, and uncached asynchronous I/O already establish request-time work. Use connection() only when you need an explicit request boundary for work Next.js would otherwise attempt during prerendering, such as time, randomness, or a synchronous database driver.

Why am I getting invalid cache-tag warnings?

The usual cause is migrating old cache-key strings into cacheTag(). Serialized queries, filters, or arbitrary key builders can produce empty, unstable, or longer-than-256-character tags. Keep query variables in function arguments and use short tags such as tenant:canprev:products for invalidation.

What should I do if TypeScript rejects cacheLife('cms')?

Use an explicit profile object or a centralized constant:

ts
cacheLife({
  stale: 300,
  revalidate: 3600,
  expire: 86400,
})

This preserves the intended caching behavior without forcing custom profile-name typing into every call site.

Why does Redis connect during next build?

Either the Redis client is created at module scope or a cached function calls the lazy initializer during prerendering. Move client construction out of module scope. Then decide whether the service is required for the static shell or should only be initialized below a request-time boundary.

Should authenticated data use 'use cache: remote'?

Usually not. Remote caches are shared server-side storage. Keep user-specific and authorization-sensitive data in an uncached request-time component unless you have designed and reviewed a safe private caching model.

Can I migrate Payload admin routes like normal frontend pages?

Do not assume so. Generated and vendor-owned routes can have their own metadata, rendering, and import behavior. Test the Payload admin separately, preserve the generated route contract, use JSX component rendering, and add route-specific loading boundaries where required.


Primary official documentation

  • Migrating to Cache Components
  • cacheComponents configuration
  • use cache
  • use cache: remote
  • cacheLife
  • cacheTag
  • updateTag
  • revalidateTag
  • connection
  • loading.tsx
  • generateStaticParams
  • Caching and rendering model
  • blocking-route troubleshooting