In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
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:
Classify every read by freshness, scope, and sensitivity.
Cache reusable public data near its source.
Stream genuinely request-time work through small Suspense boundaries.
Preserve the static page shell.
Design cache keys and tags around tenant, locale, collection, and document identity.
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.
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:
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:
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 tagcacheTag(
CACHE_KEY.PRODUCTS(tenantSlug, JSON.stringify(query)),
)
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
exportfunctioncreateCacheTag(...parts: string[]) {
const tag = parts
.map((part) => part.trim().toLowerCase())
.filter(Boolean)
.join(':')
if (!tag) {
thrownewError('Cache tag cannot be empty')
}
if (tag.length > 256) {
thrownewError(`Cache tag exceeds 256 characters: ${tag}`)
}
return tag
}
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.
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.
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>.
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:
The static shell that can be produced ahead of the request
The request-time content that will stream later
Suppose a page contains a static hero, an uncached product query, and a static footer:
Next.js prerenders the hero, product skeleton, and footer.
The browser receives and displays that shell immediately.
At request time, the server executes ProductGrid().
React streams the rendered Server Component output when the query resolves.
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:
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:
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.
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
Never add connection() to a layout until you have proved every child route streams correctly.
Keep root layout concerns static where possible.
Resolve fixed locale messages from a static input, not from request state.
Move session controls, region banners, or personalized navigation into small Suspense-wrapped children.
Cache shared navigation or settings by tenant and locale.
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:
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:
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:
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:
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
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:
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
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.
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:
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:
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.
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.
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:
Convert the wrapper to an async function.
Add 'use cache'.
Replace TTL with cacheLife().
Replace tags with cacheTag().
Make tenant and locale explicit arguments.
Test detail and list invalidation.
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:
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:
Do not sprinkle connection() at page tops.
Do not add connection() to client-shell-only pages such as onboarding.
Run a production build after mechanical edits.
Put uncached work under a Suspense boundary at the same time you introduce it.
Keep static layouts free from implicit request-locale reads.
Treat layout-level connection() as a subtree-wide architectural change.
Make every Suspense fallback independent from the dynamic tree.
Remove render-time randomness from initial Client Component state.
Use next build --debug-prerender for opaque route errors.
Include tenant slug in every tenant-dependent cache key.
Include locale when locale changes returned content.
Do not assume plain 'use cache' preserves unstable_cache durability.
Do not cache private user data in a shared remote cache.
Do not use the deprecated one-argument revalidateTag(tag) form.
Do not put 'use cache' directly on a GET Route Handler export.
Do not return an empty array from generateStaticParams() with Cache Components enabled.
Do not use Cache Components routes on the Edge runtime.
Do not use tags as a substitute for correct cache-key arguments.
Do not move old keyParts or serialized query state into cacheTag().
Do not initialize Redis, database, or external SDK connections at module scope.
Do not assume lazy client construction prevents prerender-time connections.
Check Payload admin and other vendor-owned routes separately.
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:
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.