---
title: "Next.js 16 Cache Components: The Complete unstable_cache Migration Guide"
slug: "nextjs-use-cache-migration-guide"
published: "2026-03-01"
updated: "2026-07-18"
categories:
  - "Next.js"
tags:
  - "Next.js use cache"
  - "unstable_cache"
  - "Next.js 16"
  - "server-side caching"
  - "cache key generation"
  - "cacheTag"
  - "cacheLife"
  - "revalidateTag"
  - "updateTag"
  - "extraction pattern"
  - "Server Actions"
  - "cache invalidation"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js 16"
  - "next/cache"
  - "server actions"
  - "prisma"
  - "node.js"
status: "stable"
llm-purpose: "Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…"
llm-prereqs:
  - "Access to Next.js 16"
  - "Access to next/cache"
  - "Access to Server Actions"
  - "Access to Prisma"
  - "Access to Node.js"
llm-outputs:
  - "Completed outcome: Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…"
---

**Summary Triples**
- (Next.js 'use cache' (v16): The Complete Migration Guide, focuses-on, Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…)
- (Next.js 'use cache' (v16): The Complete Migration Guide, category, general)

### {GOAL}
Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…

### {PREREQS}
- Access to Next.js 16
- Access to next/cache
- Access to Server Actions
- Access to Prisma
- Access to Node.js

### {STEPS}
1. Identify expensive shared operations
2. Extract request-specific values
3. Convert wrappers to 'use cache' functions
4. Apply cacheLife and cacheTag rules
5. Adjust invalidation to updateTag
6. Test and verify forbidden API errors

<!-- llm:goal="Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…" -->
<!-- llm:prereq="Access to Next.js 16" -->
<!-- llm:prereq="Access to next/cache" -->
<!-- llm:prereq="Access to Server Actions" -->
<!-- llm:prereq="Access to Prisma" -->
<!-- llm:prereq="Access to Node.js" -->
<!-- llm:output="Completed outcome: Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…" -->

# Next.js 16 Cache Components: The Complete unstable_cache Migration Guide
> Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…
Matija Žiberna · 2026-03-01

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](https://nextjs.org/docs/app/guides/migrating-to-cache-components), [`use cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache), and [`cacheComponents`](https://nextjs.org/docs/app/api-reference/config/next-config-js/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

| Bucket | Typical examples | Correct treatment |
| --- | --- | --- |
| Pure static | Navigation structure, fixed copy, icons | Do nothing |
| Shared cacheable data | Published CMS pages, products, recipes, site settings | `'use cache'` plus `cacheLife()` and `cacheTag()` |
| Shared but durability-sensitive data | Expensive 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-fresh | Session, cookies, authorization, live per-request read | Small 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 mechanism | Cache 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 = n` | Replace with `cacheLife()` inside a cached scope. |
| `fetchCache` | Remove 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_ppr` | Remove. 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:

| Property | Meaning |
| --- | --- |
| `stale` | How long the browser router can reuse its cached result without checking the server |
| `revalidate` | When the next server request can serve stale output and start background regeneration |
| `expire` | After this much inactivity, the next request must wait for fresh output |

Built-in profiles in Next.js 16.2.10:

| Profile | `stale` | `revalidate` | `expire` | Good fit |
| --- | ---: | ---: | ---: | --- |
| `default` | 5 min | 15 min | Never | General content |
| `seconds` | 30 sec | 1 sec | 1 min | Near-real-time shared data |
| `minutes` | 5 min | 1 min | 1 hour | Frequently updated content |
| `hours` | 5 min | 1 hour | 1 day | Inventory, weather, regular updates |
| `days` | 5 min | 1 day | 1 week | Articles and editorial pages |
| `weeks` | 5 min | 1 week | 30 days | Weekly publishing |
| `max` | 5 min | 30 days | 1 year | Rarely 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`](https://nextjs.org/docs/app/api-reference/functions/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`](https://nextjs.org/docs/app/api-reference/functions/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`](https://nextjs.org/docs/app/api-reference/functions/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`](https://nextjs.org/docs/app/api-reference/functions/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`](https://nextjs.org/docs/app/api-reference/functions/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`](https://nextjs.org/docs/app/api-reference/directives/use-cache-remote).

### Migration choice matrix

| Situation | Recommended choice |
| --- | --- |
| Published CMS data rendered into a static shell | Plain `'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 repository | Keep that layer, add plain `'use cache'` only for rendering semantics |
| Query is below roughly 50 ms and origin capacity is healthy | Plain `'use cache'` or no server runtime cache |
| Query is expensive, rate-limited, or fragile | Remote cache or existing durable application cache |
| User-specific dashboard | Uncached component under `<Suspense>` |
| Transition has uncertain infrastructure support | Retain `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:

| Treatment | Rendering behavior |
| --- | --- |
| Synchronous static UI | Included in the static shell |
| Data behind `'use cache'` | Can be computed and included in the static shell |
| Uncached asynchronous data | Fallback in the shell, real content streamed at request time |
| `cookies()`, `headers()`, or request parameters | Request-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 moment | What it means | Rule |
| --- | --- | --- |
| Module import | File is loaded by the build or runtime | Do not open connections or resolve request state |
| Prerender | Next.js executes cacheable work to build the static shell | Required services must be available during the build |
| Request time | A real request has reached the dynamic subtree | Initialize 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:

| Item | Route/data function | Current key | Current tags | TTL | Tenant-aware | Locale-aware | Durable cache required | Target 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:

| Scenario | Expected result |
| --- | --- |
| Cold production request | Correct shell and streamed regions |
| Warm request | Cached shared data reused according to the chosen storage |
| Client navigation | Static shell appears immediately where designed |
| CMS publish webhook | Matching item and list entries become stale |
| Server Action mutation | User sees the update immediately after `updateTag()` |
| Another tenant | Never receives the first tenant's content |
| Another locale | Receives the correct localized content |
| Draft Mode | Fresh preview content, no cache persistence |
| Authenticated user | No private data in shared cache |
| New deployment | Expected cold-cache behavior and acceptable origin load |
| Navigation away and back | Preserved 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](https://nextjs.org/docs/app/guides/migrating-to-cache-components)
- [`cacheComponents` configuration](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheComponents)
- [`use cache`](https://nextjs.org/docs/app/api-reference/directives/use-cache)
- [`use cache: remote`](https://nextjs.org/docs/app/api-reference/directives/use-cache-remote)
- [`cacheLife`](https://nextjs.org/docs/app/api-reference/functions/cacheLife)
- [`cacheTag`](https://nextjs.org/docs/app/api-reference/functions/cacheTag)
- [`updateTag`](https://nextjs.org/docs/app/api-reference/functions/updateTag)
- [`revalidateTag`](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
- [`connection`](https://nextjs.org/docs/app/api-reference/functions/connection)
- [`loading.tsx`](https://nextjs.org/docs/app/api-reference/file-conventions/loading)
- [`generateStaticParams`](https://nextjs.org/docs/app/api-reference/functions/generate-static-params)
- [Caching and rendering model](https://nextjs.org/docs/app/getting-started/caching)
- [`blocking-route` troubleshooting](https://nextjs.org/docs/messages/blocking-route)

## LLM Response Snippet
```json
{
  "goal": "Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…",
  "responses": [
    {
      "question": "What does the article \"Next.js 'use cache' (v16): The Complete Migration Guide\" cover?",
      "answer": "Next.js 'use cache' in v16 replaces unstable_cache—auto-generate cache keys, use cacheTag/cacheLife, follow the extraction pattern, and migrate safely…"
    }
  ]
}
```