docs/01-app/02-guides/migrating-to-cache-components.mdx
When Cache Components is enabled, route segment configs like dynamic, revalidate, and fetchCache are replaced by use cache and cacheLife.
Start by removing the route segment configs (dynamic, revalidate, fetchCache). With Cache Components enabled, Next.js surfaces uncached dynamic data as errors in development, naming the code to fix, most often uncached data to cache with use cache or runtime data to wrap in <Suspense>.
Your existing fetch and unstable_cache caching keeps working as a separate layer, so let the errors guide what to change.
Some surfaces have their own steps:
generateStaticParams guidance.generateMetadata and generateViewport guidance.The sections below cover each config and API and what to do with it under Cache Components.
Cache Components requires Next.js 16. If you're on Next.js 15 or earlier, upgrade first by following the version 16 upgrade guide. Coming from an older version, work through the upgrade guides to reach 16 before continuing.
Then enable the cacheComponents flag in next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Good to know: If you were using
experimental.dynamicIOorexperimental.useCache,cacheComponentsreplaces them. See the version 16 upgrade guide.
dynamic = "force-dynamic"Not needed. All pages are dynamic by default.
// Before - No longer needed
export const dynamic = 'force-dynamic'
export default function Page() {
return <div>...</div>
}
// Before - No longer needed
export const dynamic = 'force-dynamic'
export default function Page() {
return <div>...</div>
}
// After - Just remove it
export default function Page() {
return <div>...</div>
}
// After - Just remove it
export default function Page() {
return <div>...</div>
}
dynamic = "force-static"Start by removing it. When unhandled uncached or runtime data access is detected during development and build time, Next.js raises an error. Otherwise, the prerendering step automatically extracts the static HTML shell.
For uncached data access, add use cache as close to the data access as possible with a long cacheLife like 'max' to maintain cached behavior. If needed, add it at the top of the page or layout.
For runtime data access (cookies(), headers(), etc.), errors will direct you to wrap it with <Suspense>. Since you started by using force-static, you must remove the runtime data access to prevent any request time work.
// Before
export const dynamic = 'force-static'
export default async function Page() {
const data = await fetch('https://api.example.com/data')
return <div>...</div>
}
// Before
export const dynamic = 'force-static'
export default async function Page() {
const data = await fetch('https://api.example.com/data')
return <div>...</div>
}
import { cacheLife } from 'next/cache'
// After - Use 'use cache' instead
export default async function Page() {
'use cache'
cacheLife('max')
const data = await fetch('https://api.example.com/data')
return <div>...</div>
}
import { cacheLife } from 'next/cache'
// After - Use 'use cache' instead
export default async function Page() {
'use cache'
cacheLife('max')
const data = await fetch('https://api.example.com/data')
return <div>...</div>
}
revalidateReplace with cacheLife. Use the cacheLife function to define cache duration instead of the route segment config.
// Before
export const revalidate = 3600 // 1 hour
export default async function Page() {
return <div>...</div>
}
// Before
export const revalidate = 3600 // 1 hour
export default async function Page() {
return <div>...</div>
}
// After - Use cacheLife
import { cacheLife } from 'next/cache'
export default async function Page() {
'use cache'
cacheLife('hours')
return <div>...</div>
}
// After - Use cacheLife
import { cacheLife } from 'next/cache'
export default async function Page() {
'use cache'
cacheLife('hours')
return <div>...</div>
}
fetchCacheNot needed. With use cache, all data fetching within a cached scope is automatically cached, making fetchCache unnecessary.
// Before
export const fetchCache = 'force-cache'
// Before
export const fetchCache = 'force-cache'
// After - Use 'use cache' to control caching behavior
export default async function Page() {
'use cache'
// All fetches here are cached
return <div>...</div>
}
// After - Use 'use cache' to control caching behavior
export default async function Page() {
'use cache'
// All fetches here are cached
return <div>...</div>
}
fetch cache optionsMove cache and next options to use cache.
Without Cache Components, you cache a request with cache: 'force-cache' and tune it with next: { revalidate, tags }.
With Cache Components, wrap the fetch in a use cache function. Fetches inside that scope are cached automatically, and revalidate and tags become cacheLife and cacheTag.
// Before
export default async function Page() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache',
next: { revalidate: 3600, tags: ['data'] },
})
const data = await res.json()
return <div>...</div>
}
// Before
export default async function Page() {
const res = await fetch('https://api.example.com/data', {
cache: 'force-cache',
next: { revalidate: 3600, tags: ['data'] },
})
const data = await res.json()
return <div>...</div>
}
// After
import { cacheLife, cacheTag } from 'next/cache'
async function getData() {
'use cache'
cacheLife('hours')
cacheTag('data')
const res = await fetch('https://api.example.com/data')
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>...</div>
}
// After
import { cacheLife, cacheTag } from 'next/cache'
async function getData() {
'use cache'
cacheLife('hours')
cacheTag('data')
const res = await fetch('https://api.example.com/data')
return res.json()
}
export default async function Page() {
const data = await getData()
return <div>...</div>
}
Note the persistence difference. The fetch Data Cache persists cached responses across deployments and across serverless instances.
use cache defaults to in-memory storage, so its entries are discarded when the serverless instance is destroyed and are scoped to a single deployment. Use use cache: remote or a cache handler for storage that survives instance teardown. Even with durable storage, expect cached values to recompute after a new deployment.
unstable_cacheReplace with use cache.
unstable_cache is replaced by the use cache directive.
Turn the wrapped function into a function with the 'use cache' directive. The cache key is derived automatically from the arguments, so the key-parts array is no longer needed, and the options object maps to cacheLife and cacheTag.
// Before
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
export const getUser = unstable_cache(
async (id: string) => {
return db.query.users.findFirst({ where: eq(users.id, id) })
},
['user'], // cache key prefix
{ tags: ['users'], revalidate: 3600 }
)
// Before
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
export const getUser = unstable_cache(
async (id) => {
return db.query.users.findFirst({ where: eq(users.id, id) })
},
['user'], // cache key prefix
{ tags: ['users'], revalidate: 3600 }
)
// After
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
export async function getUser(id: string) {
'use cache'
cacheLife('hours')
cacheTag('users')
return db.query.users.findFirst({ where: eq(users.id, id) })
}
// After
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
export async function getUser(id) {
'use cache'
cacheLife('hours')
cacheTag('users')
return db.query.users.findFirst({ where: eq(users.id, id) })
}
Like the fetch Data Cache, unstable_cache persists cached values across deployments and serverless instances, while use cache does not. See fetch cache options above for the storage details.
revalidateTag, revalidatePath, updateTag)On-demand invalidation still works by tagging cached data and expiring it after an event. Tag data with cacheTag inside a use cache function instead of the fetch next.tags option, then choose the invalidation API by the behavior you want:
updateTag: for mutations whose result the user must see immediately (read-your-own-writes). Called from a Server Action, it expires the tag so the next request waits for fresh data instead of serving stale content.revalidateTag: for stale-while-revalidate. Pass a cache profile like 'max' to serve cached data while it refreshes in the background. Works in Server Actions and Route Handlers.revalidatePath: unchanged from the previous caching model.updateTag isn't exclusive to Cache Components (it also works with the previous caching model), but migrating is a good time to adopt it. After a mutation in a Server Action, reach for it when the user should see their own change right away.
'use server'
import { updateTag } from 'next/cache'
export async function createPost(formData: FormData) {
// Create the post, then show it immediately on the next request
updateTag('posts')
}
'use server'
import { updateTag } from 'next/cache'
export async function createPost(formData) {
// Create the post, then show it immediately on the next request
updateTag('posts')
}
Good to know:
updateTagcan only be called from a Server Action; calling it elsewhere throws. In Route Handlers or webhooks, userevalidateTaginstead.
unstable_noStoreNot needed. unstable_noStore (noStore()) opts a component out of caching. With Cache Components, nothing is cached unless you add use cache, so you can remove it. If a component must run at request time, call connection() before the work and wrap it in <Suspense>.
// Before
import { unstable_noStore as noStore } from 'next/cache'
export default async function Page() {
noStore()
const data = await db.query('...')
return <div>...</div>
}
// Before
import { unstable_noStore as noStore } from 'next/cache'
export default async function Page() {
noStore()
const data = await db.query('...')
return <div>...</div>
}
// After - uncached by default, just remove noStore()
export default async function Page() {
const data = await db.query('...')
return <div>...</div>
}
// After - uncached by default, just remove noStore()
export default async function Page() {
const data = await db.query('...')
return <div>...</div>
}
generateStaticParams and dynamicParamsOne behavior changes for dynamic routes when Cache Components is enabled.
generateStaticParams must return at least one paramReturning an empty array now errors. Without Cache Components, returning [] defers every path to the first runtime visit. With Cache Components, generateStaticParams must return at least one param so Next.js can prerender the route. An empty array raises empty-generate-static-params.
// Before - defer all paths to runtime
export async function generateStaticParams() {
return []
}
// Before - defer all paths to runtime
export async function generateStaticParams() {
return []
}
// After - return at least one param to prerender
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())
return posts.slice(0, 1).map((post) => ({ slug: post.slug }))
}
// After - return at least one param to prerender
export async function generateStaticParams() {
const posts = await fetch('https://.../posts').then((res) => res.json())
return posts.slice(0, 1).map((post) => ({ slug: post.slug }))
}
cookies, headers, and searchParamsWrap runtime data access in <Suspense>. Without Cache Components, reading cookies(), headers(), or searchParams opts the whole route into dynamic rendering. With Cache Components, accessing them outside a <Suspense> boundary surfaces the blocking-route insight. Move the access into a component wrapped in <Suspense> so the rest of the page prerenders as a static shell and the dynamic part streams in at request time.
import { cookies } from 'next/headers'
// Before - reading cookies at the top makes the whole route dynamic
export default async function Page() {
const theme = (await cookies()).get('theme')?.value
return <Dashboard theme={theme} />
}
import { cookies } from 'next/headers'
// Before - reading cookies at the top makes the whole route dynamic
export default async function Page() {
const theme = (await cookies()).get('theme')?.value
return <Dashboard theme={theme} />
}
import { cookies } from 'next/headers'
import { Suspense } from 'react'
// After - the page prerenders; only Dashboard streams at request time
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Dashboard />
</Suspense>
)
}
async function Dashboard() {
const theme = (await cookies()).get('theme')?.value
// ...
}
import { cookies } from 'next/headers'
import { Suspense } from 'react'
// After - the page prerenders; only Dashboard streams at request time
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Dashboard />
</Suspense>
)
}
async function Dashboard() {
const theme = (await cookies()).get('theme')?.value
// ...
}
Your page receives params and searchParams as props, and both are promises. Apply the same pattern: pass the promise straight through to the <Suspense>-wrapped component as a prop and await it there, rather than at the top of the page. You can also unwrap the promise inline with .then() and pass a plain value down; see Streaming for a similar pattern.
import { Suspense } from 'react'
export default function Page({ searchParams }: PageProps<'/'>) {
return (
<Suspense fallback={<p>Loading...</p>}>
<Results searchParams={searchParams} />
</Suspense>
)
}
async function Results({ searchParams }: Pick<PageProps<'/'>, 'searchParams'>) {
const { query } = await searchParams
// ...
}
import { Suspense } from 'react'
export default function Page({ searchParams }) {
return (
<Suspense fallback={<p>Loading...</p>}>
<Results searchParams={searchParams} />
</Suspense>
)
}
async function Results({ searchParams }) {
const { query } = await searchParams
// ...
}
GET)Replace dynamic = 'force-static' with use cache.
Without Cache Components, a GET Route Handler is dynamic unless you opt into caching with export const dynamic = 'force-static'. With Cache Components, GET handlers follow the same model as pages: they prerender when they don't access uncached or runtime data, and you cache uncached data with use cache. Remove the dynamic config and move the data access into a separate function marked with use cache. The directive can't be applied to the GET export itself, so the handler calls a cached helper.
// Before
export const dynamic = 'force-static'
export async function GET() {
const products = await db.query('SELECT * FROM products')
return Response.json(products)
}
// Before
export const dynamic = 'force-static'
export async function GET() {
const products = await db.query('SELECT * FROM products')
return Response.json(products)
}
// After
import { cacheLife } from 'next/cache'
export async function GET() {
const products = await getProducts()
return Response.json(products)
}
async function getProducts() {
'use cache'
cacheLife('hours')
return db.query('SELECT * FROM products')
}
// After
import { cacheLife } from 'next/cache'
export async function GET() {
const products = await getProducts()
return Response.json(products)
}
async function getProducts() {
'use cache'
cacheLife('hours')
return db.query('SELECT * FROM products')
}
Good to know: Reading uncached or runtime data in a
GEThandler bails out of prerendering by throwing. Atry/catchyou already have around other operations will catch that bail-out. If thecatchblock logs the error, it adds noise to the build output. Setexperimental.hideLogsAfterAbort: trueto hide logs emitted after a bail-out.
generateMetadata and generateViewportCache external data with use cache, or mark intentionally dynamic pages. Under Cache Components, generateMetadata and generateViewport follow the same rules as components. If they read runtime data (cookies(), headers(), params, searchParams) or fetch uncached data while the rest of the page is otherwise prerenderable, Next.js raises an error so the choice is explicit. If the metadata depends on external but not runtime data, add use cache.
// Before
export async function generateMetadata() {
const { title, description } = await db.query('site-metadata')
return { title, description }
}
// After - cache external data
export async function generateMetadata() {
'use cache'
const { title, description } = await db.query('site-metadata')
return { title, description }
}
If the metadata genuinely needs runtime data, you can't wrap generateMetadata in <Suspense>. Instead, add a dynamic marker component to the page so the static content still prerenders while the metadata streams in.
import { Suspense } from 'react'
import { connection } from 'next/server'
export async function generateMetadata() {
// reads runtime data
return { title: 'Personalized Title' }
}
async function DynamicMarker() {
return (
<Suspense>
<Connection />
</Suspense>
)
}
async function Connection() {
await connection()
return null
}
export default function Page() {
return (
<>
<article>Static content</article>
<DynamicMarker />
</>
)
}
See generateMetadata with Cache Components and generateViewport with Cache Components for the full set of fix options and trade-offs.
runtime = 'edge'Not supported. Cache Components requires the Node.js runtime. Switch to the Node.js runtime (the default) by removing the runtime = 'edge' export. If you need edge behavior for specific routes, use Proxy instead.
experimental_pprRemoved. Enable cacheComponents instead. Next.js 16 removes the experimental Partial Prerendering flag (experimental.ppr) and the experimental_ppr route segment config. Partial Prerendering is now part of Cache Components, so remove experimental.ppr from next.config and experimental_ppr from your segments. A codemod removes the segment config for you.
// Before - no longer needed
export const experimental_ppr = true
export default function Page() {
return <div>...</div>
}
// Before - no longer needed
export const experimental_ppr = true
export default function Page() {
return <div>...</div>
}
// After - remove it; cacheComponents enables Partial Prerendering
export default function Page() {
return <div>...</div>
}
// After - remove it; cacheComponents enables Partial Prerendering
export default function Page() {
return <div>...</div>
}
Component state now persists across navigations. With Cache Components, Next.js preserves routes using React's <Activity> component in "hidden" mode instead of unmounting them. Effects clean up and re-run normally, but useState values, form inputs, and scroll position are no longer reset when navigating away and back.
If your code relied on unmounting to clear state, you may need to add explicit reset logic:
useLayoutEffect cleanup function.useActionState results (success/error messages) persist when returning. Reset in the submit handler or user action when possible, otherwise use a cleanup effect.See Preserving UI state across navigations for detailed examples of each pattern.