Complete reference and code examples for migrating to Next.js 16 stable.
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20.9+ | Node.js 18 no longer supported |
| TypeScript | 5.1+ | TypeScript 5.0 minimum |
| Browsers | Chrome 111+, Edge 111+, Firefox 111+, Safari 16.4+ | Updated minimum versions |
1. Async Request APIs - params, searchParams are now Promises
- Affected: Pages, Layouts, Route Handlers,
generateMetadata,generateViewport, metadata image routes - Pattern:
function Page({ params })→async function Page(props)+await props.params
2. Async Dynamic Functions - cookies(), headers(), draftMode() return Promises
- Pattern:
cookies().get()→(await cookies()).get()
3. revalidateTag API - Now requires profile parameter
updateTag(tag)for Server Actions (read-your-own-writes, no profile parameter)revalidateTag(tag, profile)for Route Handlers (background invalidation, requires profile)
- AMP Support: All AMP APIs removed
- Runtime Config:
serverRuntimeConfig,publicRuntimeConfig→ use.envfiles - PPR Flags:
experimental.ppr,experimental_ppr→ useexperimental.cacheComponents - experimental.dynamicIO: Renamed to
experimental.cacheComponents - unstable_rootParams(): Removed (alternative coming)
- Auto scroll-behavior: No longer automatic (add
data-scroll-behavior="smooth"to<html>if needed) - devIndicators options:
appIsrStatus,buildActivity,buildActivityPositionremoved
- Turbopack: Now default (remove
--turbopackflags, use--webpackif needed) - ESLint config: Remove from next.config.js, move to
.eslintrc.json - serverComponentsExternalPackages: Move from
experimentalto top-level - Middleware → Proxy: Rename
middleware.ts→proxy.ts(deprecated but works)
✅ Node.js 20.9+, TypeScript 5.1+
✅ Remove: AMP, runtime configs, PPR flags, devIndicators options
✅ Make async: All functions using params, searchParams, cookies(), headers(), draftMode()
✅ Update: revalidateTag() → updateTag() or revalidateTag(tag, profile)
✅ Config: Remove ESLint config, move serverComponentsExternalPackages to top-level
✅ Parallel Routes: Add default.js for @ folders
✅ Dependencies: Upgrade @types/react and @types/react-dom to latest
- Removed Features Examples
- Parallel Routes Examples
- Image Configuration Examples
- Config Migration Examples
- Async API Migration Examples
- Cache Invalidation Examples
- Middleware to Proxy Examples
- unstable_noStore Examples
- Cache Components Examples
# Search for AMP usage
grep -r "useAmp\|amp:" app/ src/ pages/Migration:
// ❌ BEFORE - Remove these
import { useAmp } from 'next/amp'
export default function Page() {
const isAmp = useAmp()
// ...
}
export const config = { amp: true }
// ✅ AFTER - No replacement
// Remove all AMP code
// Consider alternative approaches for mobile performance# Search for runtime config
grep -r "serverRuntimeConfig\|publicRuntimeConfig" next.config.*Migration:
// ❌ BEFORE - next.config.js
module.exports = {
- serverRuntimeConfig: { apiKey: 'secret' },
- publicRuntimeConfig: { apiUrl: 'https://api.example.com' }
}
// ✅ AFTER - Use .env files
// .env.local
API_KEY=secret
NEXT_PUBLIC_API_URL=https://api.example.comUsage:
// In your code
const apiKey = process.env.API_KEY // Server-side only
const apiUrl = process.env.NEXT_PUBLIC_API_URL // Client and server# Search for PPR configs
grep -r "experimental.ppr\|experimental_ppr" next.config.* app/ src/Migration:
// ❌ BEFORE - next.config.js
module.exports = {
- experimental: {
- ppr: true,
- }
}
// ❌ BEFORE - app/page.tsx
- export const experimental_ppr = true
// ✅ AFTER - Use Cache Components model
module.exports = {
experimental: {
cacheComponents: true, // New Cache Components model
}
}# Search for old flag
grep -r "experimental.dynamicIO" next.config.*Migration:
// next.config.js
module.exports = {
experimental: {
- dynamicIO: true,
+ cacheComponents: true,
}
}# Search for usage
grep -r "unstable_rootParams" app/ src/Migration:
// ❌ BEFORE
import { unstable_rootParams } from 'next/navigation'
export default function Page() {
const params = unstable_rootParams()
// ...
}
// ✅ AFTER - Temporary workaround
// Use params from props instead
export default async function Page(props) {
const params = await props.params
// ...
}
// Note: Alternative API coming in upcoming minor releaseMigration:
// ❌ BEFORE - This was automatic
// Next.js automatically added scroll-behavior: smooth
// ✅ AFTER - Add manually if needed
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html data-scroll-behavior="smooth">
<body>{children}</body>
</html>
)
}# Search for dev indicators config
grep -r "devIndicators" next.config.*Migration:
// next.config.js
module.exports = {
- devIndicators: {
- appIsrStatus: true,
- buildActivity: true,
- buildActivityPosition: 'bottom-right',
- }
}
// Note: The dev indicator itself remains, just these config options are removed# Find all parallel route folders
find app -type d -name "@*" | grep -v "@children"Migration:
// Create: app/@modal/default.js (for @modal, @auth, etc.)
export default function Default() {
return null
}
// Or if you want to show notFound
import { notFound } from 'next/navigation'
export default function Default() {
notFound()
}Note: @children is a special implicit slot and does NOT require a default.js file.
// next.config.js
module.exports = {
+ images: {
+ localPatterns: [{ pathname: '/img/**' }]
+ }
}When is this needed? If you use local images with query strings like:
import Image from 'next/image'
// This requires localPatterns config
<Image src="/img/photo.jpg?v=123" alt="Photo" width={500} height={300} />Defaults that changed in v16:
// next.config.js - Override if needed
module.exports = {
images: {
// Old default: 60, New default: 14400 (4 hours)
minimumCacheTTL: 14400,
// Old default: [1..100], New default: [75]
qualities: [75],
// Old default: [16, 32, 48, 64, 96, 128, 256, 384]
// New default: [32, 48, 64, 96, 128, 256, 384] (removed 16)
imageSizes: [32, 48, 64, 96, 128, 256, 384],
// Old default: undefined (allowed), New default: false
dangerouslyAllowLocalIP: false,
// Old default: unlimited, New default: 3
maximumRedirects: 3,
}
}# Search for ESLint config in next.config
grep -r "eslint:" next.config.*Migration:
// ❌ BEFORE - next.config.js
module.exports = {
- eslint: {
- ignoreDuringBuilds: true,
- dirs: ['app', 'src'],
- },
}
// ✅ AFTER - Move to .eslintrc.json
// .eslintrc.json
{
"extends": "next/core-web-vitals",
"ignorePatterns": ["node_modules/", ".next/"]
}
// Or use the codemod:
// npx @next/codemod@canary next-lint-to-eslint-cli .// next.config.js
module.exports = {
- experimental: {
- serverComponentsExternalPackages: ['package-name'],
- },
+ serverComponentsExternalPackages: ['package-name'],
}// next.config.js
module.exports = {
- turbopackPersistentCachingForDev: true,
+ turbopackFileSystemCacheForDev: true,
}// package.json
{
"scripts": {
- "dev": "next dev --turbopack",
- "build": "next build --turbopack"
+ "dev": "next dev",
+ "build": "next build"
}
}
// If you need webpack instead, use --webpack flag:
// "dev": "next dev --webpack"// ❌ BEFORE (Next.js 15)
// app/blog/[slug]/opengraph-image.tsx
export default function Image({ params, id }) {
const slug = params.slug
const imageId = id // string
// Generate image...
}
export async function generateImageMetadata({ params }) {
return [
{ id: 'default', size: { width: 1200, height: 630 } },
{ id: 'large', size: { width: 1800, height: 945 } }
]
}
// ✅ AFTER (Next.js 16)
// app/blog/[slug]/opengraph-image.tsx
export default async function Image({ params, id }) {
const resolvedParams = await params // params is now a Promise
const slug = resolvedParams.slug
const imageId = id // string (id itself is not a Promise)
// Generate image...
}
export async function generateImageMetadata({ params }) {
return [
{ id: 'default', size: { width: 1200, height: 630 } },
{ id: 'large', size: { width: 1800, height: 945 } }
]
}// ❌ WRONG - Cannot destructure Promise
export default async function Page({ params }) {
// params is still a Promise here!
const slug = params.slug // ERROR
}
// ❌ WRONG - Cannot destructure in signature
export default async function Page({ params: { slug } }) {
// ERROR: Cannot destructure Promise
}
// ✅ CORRECT
export default async function Page(props) {
const params = await props.params
const slug = params.slug
}
// ✅ CORRECT - Destructure after awaiting
export default async function Page(props) {
const { slug } = await props.params
}// ✅ CORRECT - Always await even in conditionals
export default async function Page(props) {
const searchParams = await props.searchParams
if (searchParams.debug) {
console.log('Debug mode enabled')
}
return <div>...</div>
}// ❌ BEFORE
export async function GET(request: Request, { params }) {
const id = params.id
return Response.json({ id })
}
// ✅ AFTER
export async function GET(request: Request, props) {
const params = await props.params
const id = params.id
return Response.json({ id })
}# Find all revalidateTag calls
grep -r "revalidateTag(" app/ src/Migration:
// ❌ OLD (deprecated)
import { revalidateTag } from 'next/cache'
export async function createPost(data: FormData) {
'use server'
await db.posts.create(data)
revalidateTag('posts') // Deprecated signature
}
// ✅ OPTION 1: Use updateTag for Server Actions (read-your-own-writes)
import { updateTag } from 'next/cache'
export async function createPost(data: FormData) {
'use server'
await db.posts.create(data)
updateTag('posts') // Immediate consistency (read-your-own-writes)
}
// ✅ OPTION 2: Use revalidateTag with profile (background invalidation)
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
await db.posts.create(await request.json())
revalidateTag('posts', 'max') // Background invalidation
return Response.json({ success: true })
}When to use which:
| API | Use Case | Behavior |
|---|---|---|
updateTag('tag') |
Server Actions needing immediate reads | Read-your-own-writes semantics, no profile parameter |
revalidateTag('tag', 'max') |
Route Handlers or background updates | Background invalidation with profile |
cacheLife Profiles:
// Common profiles to use as second argument
'max' // Maximum staleness
'hours' // Medium staleness
'minutes' // Minimal staleness
'default' // Default profile# Rename the file
mv middleware.ts proxy.ts- // middleware.ts
- export function middleware(request) {
+ // proxy.ts
+ export function proxy(request) {
return NextResponse.next()
}
- export const config = {
+ export const config = {
matcher: '/api/:path*',
}# Find middleware config usage
grep -r "middlewarePrefetch\|middlewareClientMaxBodySize\|externalMiddlewareRewritesResolve\|skipMiddlewareUrlNormalize" .Migration:
// next.config.js
module.exports = {
experimental: {
- middlewarePrefetch: 'strict',
+ proxyPrefetch: 'strict',
- middlewareClientMaxBodySize: 1024,
+ proxyClientMaxBodySize: 1024,
- externalMiddlewareRewritesResolve: true,
+ externalProxyRewritesResolve: true,
},
- skipMiddlewareUrlNormalize: true,
+ skipProxyUrlNormalize: true,
}IMPORTANT: unstable_noStore() is only incompatible when Cache Components are enabled. If you're not using experimental.cacheComponents, you can continue using it.
# Find all unstable_noStore usage
grep -r "unstable_noStore" app/ src/- import { unstable_noStore } from 'next/cache'
export default async function Page() {
- unstable_noStore() // Opt-out of static rendering
+ // MIGRATED: Removed unstable_noStore() - dynamic by default with Cache Components
+ // This component executes on every request (dynamic behavior)
const data = await fetch('https://api.example.com/data')
return <div>{data}</div>
}- import { unstable_noStore } from 'next/cache'
+ import { Suspense } from 'react'
export default async function Page() {
- unstable_noStore()
+ // MIGRATED: Removed unstable_noStore() and added Suspense boundary
+ // Dynamic content wrapped in Suspense for better UX
+ return (
+ <Suspense fallback={<Loading />}>
+ <DynamicContent />
+ </Suspense>
+ )
+ }
+ async function DynamicContent() {
+ // No unstable_noStore() needed - dynamic by default
const data = await fetch('https://api.example.com/data')
return <div>{data}</div>
}If you realize the content should actually be cached:
- import { unstable_noStore } from 'next/cache'
+ import { cacheLife } from 'next/cache'
export default async function Page() {
- unstable_noStore() // Was preventing caching
+ "use cache"
+ // MIGRATED: Removed unstable_noStore() - decided to cache this content instead
+ // DECISION: Content changes hourly, cacheable to reduce server load
+
+ // Uncomment to enable time-based revalidation:
+ // cacheLife('hours')
const data = await fetch('https://api.example.com/data')
return <div>{data}</div>
}Before:
// app/dashboard/page.tsx
import { unstable_noStore } from 'next/cache'
export default async function Dashboard() {
unstable_noStore() // Make everything dynamic
const user = await getCurrentUser()
const stats = await getStats()
const settings = await getSettings()
return (
<div>
<Header user={user} />
<Stats data={stats} />
<Settings data={settings} />
</div>
)
}After (Hybrid Approach):
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
// MIGRATED: Removed unstable_noStore()
// Now using hybrid approach - cache static parts, dynamic user content
export default async function Dashboard() {
return (
<div>
<CachedHeader />
<Suspense fallback={<StatsSkeleton />}>
<DynamicStats />
</Suspense>
<Suspense fallback={<SettingsSkeleton />}>
<DynamicSettings />
</Suspense>
</div>
)
}
async function CachedHeader() {
"use cache"
// cacheLife('hours') // Uncomment to enable revalidation
// Static header - same for all users
const settings = await getGlobalSettings()
return <Header settings={settings} />
}
async function DynamicStats() {
// Dynamic per user - no unstable_noStore needed
const user = await getCurrentUser()
const stats = await getStats(user.id)
return <Stats data={stats} />
}
async function DynamicSettings() {
// Dynamic per user - no unstable_noStore needed
const user = await getCurrentUser()
const settings = await getUserSettings(user.id)
return <Settings data={settings} />
}Old Caching Model (Next.js 15 and earlier):
- Everything was static by default
unstable_noStore()opted out of caching- Used to make routes dynamic
New Cache Components Model (Next.js 16 with cacheComponents):
- Everything is dynamic by default
"use cache"opts into cachingunstable_noStore()is redundant and causes errors
Key Insight: The paradigm is reversed. You no longer need to opt-out of caching; instead, you opt-in to caching only where it makes sense.
# Find ViewTransition usage
grep -r "unstable_ViewTransition" app/ src/Migration:
- import { unstable_ViewTransition } from 'react'
+ import { ViewTransition } from 'react'
export default function App({ children }) {
return (
- <unstable_ViewTransition>
+ <ViewTransition>
{children}
- </unstable_ViewTransition>
+ </ViewTransition>
)
}// next.config.js
module.exports = {
- experimental: {
- viewTransition: true,
- },
}// package.json
{
"scripts": {
- "lint": "next lint"
+ "lint": "eslint ."
}
}// package.json
{
"scripts": {
- "lint": "next lint"
+ "lint": "biome check ."
}
}# Automated migration to ESLint CLI
npx @next/codemod@canary next-lint-to-eslint-cli .Here's a complete before/after example of a typical Next.js page:
// app/blog/[slug]/page.tsx
import { cookies, headers } from 'next/headers'
export const dynamic = 'force-static' // Will cause error
export const revalidate = 3600 // Will cause error
export default function BlogPost({ params, searchParams }) {
const slug = params.slug
const highlight = searchParams.highlight
const token = cookies().get('token')
const userAgent = headers().get('user-agent')
return <div>Post: {slug}</div>
}
export async function generateMetadata({ params }) {
return {
title: `Blog Post: ${params.slug}`
}
}// app/blog/[slug]/page.tsx
import { cookies, headers } from 'next/headers'
// Removed: dynamic, revalidate (incompatible with cacheComponents)
export default async function BlogPost(props) {
const params = await props.params
const searchParams = await props.searchParams
const slug = params.slug
const highlight = searchParams.highlight
const token = (await cookies()).get('token')
const userAgent = (await headers()).get('user-agent')
return <div>Post: {slug}</div>
}
export async function generateMetadata(props) {
const params = await props.params
return {
title: `Blog Post: ${params.slug}`
}
}// ❌ BEFORE - Using runtime config
// next.config.js
module.exports = {
serverRuntimeConfig: {
apiKey: process.env.API_KEY,
dbUrl: process.env.DATABASE_URL,
},
publicRuntimeConfig: {
apiUrl: process.env.API_URL,
}
}
// Usage
import getConfig from 'next/config'
const { serverRuntimeConfig, publicRuntimeConfig } = getConfig()
console.log(serverRuntimeConfig.apiKey)
console.log(publicRuntimeConfig.apiUrl)
// ✅ AFTER - Using environment variables
// .env.local
API_KEY=secret_key_here
DATABASE_URL=postgres://...
NEXT_PUBLIC_API_URL=https://api.example.com
// Usage - Direct access
console.log(process.env.API_KEY) // Server-side only
console.log(process.env.DATABASE_URL) // Server-side only
console.log(process.env.NEXT_PUBLIC_API_URL) // Client and serverKey differences:
- Server-only variables: Regular env vars (e.g.,
API_KEY) - Public variables: Prefix with
NEXT_PUBLIC_(e.g.,NEXT_PUBLIC_API_URL) - No need to import
getConfig - Direct access via
process.env
When enabling Cache Components, you may encounter errors from third-party packages in node_modules/. Here are common workaround patterns:
// ⚠️ 3RD PARTY PACKAGE ISSUE: [package-name@version]
// Error: [error message from build]
// Source: node_modules/[package-name]/[file]
// Status: [Workaround applied / Cannot fix / Reported to package maintainer]Most common workaround - wrap the component using the package:
// ⚠️ 3RD PARTY PACKAGE ISSUE: analytics-widget@1.2.3
// Error: Package uses dynamic values without proper async handling
// Source: node_modules/analytics-widget/dist/index.js
// Status: Workaround applied - wrapped in Suspense boundary
import { Suspense } from 'react'
import { AnalyticsWidget } from 'analytics-widget'
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsWidget />
</Suspense>
</div>
)
}Load the package only when needed:
// ⚠️ 3RD PARTY PACKAGE ISSUE: heavy-chart-library@2.0.0
// Error: Package blocks initial render
// Source: node_modules/heavy-chart-library/dist/Chart.js
// Status: Workaround applied - using dynamic import
import { Suspense } from 'react'
import dynamic from 'next/dynamic'
const ChartComponent = dynamic(() => import('heavy-chart-library').then(mod => mod.Chart), {
loading: () => <div>Loading chart...</div>,
ssr: false // Disable server-side rendering if needed
})
export default function Page() {
return (
<div>
<h1>Sales Dashboard</h1>
<ChartComponent data={salesData} />
</div>
)
}Isolate package usage in its own component:
// ⚠️ 3RD PARTY PACKAGE ISSUE: payment-sdk@3.1.0
// Error: Package expects sync context
// Source: node_modules/payment-sdk/dist/PaymentForm.js
// Status: Workaround applied - isolated in separate component
import { Suspense } from 'react'
export default function CheckoutPage() {
return (
<div>
<h1>Checkout</h1>
<Suspense fallback={<div>Loading payment form...</div>}>
<PaymentFormWrapper />
</Suspense>
</div>
)
}
async function PaymentFormWrapper() {
// Separate component to handle the problematic package
const { PaymentForm } = await import('payment-sdk')
return <PaymentForm />
}When adding "use cache" directives, always include commented import templates to guide developers on revalidation strategies.
// ⚠️ CACHING STRATEGY DECISION NEEDED:
// This component uses "use cache" - decide on revalidation strategy
//
// Uncomment ONLY ONE of the following strategies based on your needs:
// Option A: Time-based revalidation (most common)
// import { cacheLife } from 'next/cache';
// cacheLife('hours'); // Revalidates every hour, expires after 1 day
// Option B: On-demand tag-based revalidation
// import { cacheTag } from 'next/cache';
// cacheTag('resource-name'); // Tag for manual revalidation via updateTag/revalidateTag
// Option C: Long-term caching (use sparingly)
// import { cacheLife } from 'next/cache';
// cacheLife('max'); // Revalidates every 30 days, cached for 1 year
// Option D: Short-lived cache (frequently updated content)
// import { cacheLife } from 'next/cache';
// cacheLife('minutes'); // Revalidates every minute, expires after 1 hour
// Option E: Custom inline profile (advanced)
// import { cacheLife } from 'next/cache';
// cacheLife({
// stale: 300, // Client caches for 5 minutes
// revalidate: 3600, // Revalidates every hour
// expire: 86400 // Expires after 24 hours
// });
export default async function Page() {
"use cache";
// User should uncomment and configure ONE of the cacheLife/cacheTag options above
const data = await fetch('...');
return <div>{data}</div>;
}For content that changes on a predictable schedule:
// DECISION: Blog posts change daily, cached for speed
// Using 'hours' profile: revalidates every hour, expires after 1 day
import { cacheLife } from 'next/cache';
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
"use cache";
cacheLife('hours'); // Uncommented after decision made
const { slug } = await params;
const post = await fetchFromCMS(slug);
return <article>{post.content}</article>;
}When to use:
- Content that changes on predictable schedules
- User-facing pages that can show slightly stale data
- High-traffic routes needing caching performance
For content that updates based on specific events:
// DECISION: Product details cached, revalidate on inventory changes
// Use cacheTag to manually trigger revalidation when product updates
import { cacheTag } from 'next/cache';
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
"use cache";
const { id } = await params;
cacheTag('products', `product-${id}`); // Multiple tags for granular control
const product = await fetchProduct(id);
return <ProductDisplay product={product} />;
}
// In your admin panel or API route:
// import { updateTag } from 'next/cache';
// await updateTag('products'); // Revalidate all products
// await updateTag(`product-${id}`); // Revalidate specific productWhen to use:
- Content that updates unpredictably (admin actions)
- E-commerce products with inventory changes
- CMS-managed content with manual publish events
- Multiple related resources that revalidate together
For truly immutable content:
// DECISION: Content rarely changes (archived pages, historical data)
// Using 'max' profile: revalidates every 30 days, cached for 1 year
import { cacheLife } from 'next/cache';
export default async function ArchivePage({ params }: { params: Promise<{ year: string }> }) {
"use cache";
cacheLife('max');
const { year } = await params;
const archiveData = await fetchArchive(year);
return <Archive data={archiveData} />;
}When to use:
- Truly immutable content (historical data, archived pages)
- Reference content that never changes
- Static files rendered as components
For frequently updating content:
// DECISION: Metrics update frequently, need low revalidation time
// Using 'minutes' profile: revalidates every minute, expires after 1 hour
import { cacheLife } from 'next/cache';
export default async function RealtimeMetrics() {
"use cache";
cacheLife('minutes');
const metrics = await fetchMetrics();
return <Dashboard metrics={metrics} />;
}When to use:
- Dashboards and real-time data
- Leaderboards and rankings
- Stock prices and live data
- Activity feeds
For complex revalidation scenarios:
// DECISION: Cache user dashboard with multiple revalidation triggers
// Revalidate on: user profile changes, new comments, new notifications
import { cacheTag } from 'next/cache';
export default async function UserDashboard({ params }: { params: Promise<{ userId: string }> }) {
"use cache";
const { userId } = await params;
// Multiple tags for different revalidation scenarios
cacheTag('user-dashboard', `user-${userId}`);
cacheTag('user-profile', `user-${userId}`);
cacheTag('user-comments', `user-${userId}`);
cacheTag('user-notifications', `user-${userId}`);
const dashboard = await buildDashboard(userId);
return <Dashboard data={dashboard} />;
}// DECISION: Header is shared (cache it), user content is personal (dynamic)
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
export default async function Page() {
return (
<div>
<CachedHeader />
<Suspense fallback={<Loading />}>
<DynamicUserContent />
</Suspense>
</div>
)
}
async function CachedHeader() {
"use cache";
cacheLife('hours');
cacheTag('site-settings');
// Static: Same for all users, changes infrequently
const settings = await fetch('https://api.cms.com/settings');
return <header>{/* ... */}</header>;
}
async function DynamicUserContent() {
// Dynamic: Per-request, user-specific
const user = await getCurrentUser();
return <div>{user.notifications}</div>;
}When content uses cookies but should be prefetchable:
// DECISION: Uses cookies but can be prefetched during navigation
// Changes per user but can be rendered ahead of actual navigation
import { cookies } from 'next/headers'
export default async function UserPreferences() {
"use cache: private";
const cookieStore = await cookies();
const userId = cookieStore.get('userId');
// Will be prefetched with actual cookie values during navigation
const userData = await fetch(`https://api.example.com/users/${userId}`);
return <div>{/* render */}</div>;
}When encountering Cache Components errors, use this decision framework:
- ✅ YES → Strong candidate for
"use cache" - ❌ NO → Consider Suspense or
"use cache: private"
- Rarely (days/weeks):
"use cache"with longcacheLife- Marketing pages, documentation - Occasionally (hours):
"use cache"with mediumcacheLife- Blog posts, product catalogs - Frequently (minutes):
"use cache"with shortcacheLife- News feeds, leaderboards - Constantly (per-request): Use Suspense - User auth state, shopping cart, notifications
- ✅ YES, from cookies/session → Use Suspense OR
"use cache: private" - ✅ YES, from route params → Can use
"use cache"+generateStaticParams - ❌ NO → Use
"use cache"
- ✅ YES (CMS updates, admin actions) → Use
"use cache"+cacheTag() - ❌ NO (no clear trigger) → Use time-based
cacheLifeor Suspense
Approach A: Cache It (Static)
// DECISION: Shared across users, changes rarely (daily)
// Cached to reduce server load and enable instant navigation
export default async function Page() {
"use cache";
cacheLife('hours'); // Revalidates every hour
cacheTag('blog-posts'); // Enable on-demand revalidation
const posts = await fetch('http://api.cms.com/posts');
return <div>{/* render */}</div>;
}Approach B: Make It Dynamic (Per-Request)
// DECISION: User-specific, changes per request
// Using Suspense to show loading state while fetching fresh data
export default async function Page() {
return (
<Suspense fallback={<Skeleton />}>
<UserDashboard />
</Suspense>
);
}
async function UserDashboard() {
const user = await getCurrentUser();
return <div>{user.name}</div>;
}Approach C: Mix Both (Hybrid)
// DECISION: Header is shared (cache it), user content is personal (dynamic)
export default async function Page() {
return (
<div>
<CachedHeader />
<Suspense fallback={<Loading />}>
<DynamicUserContent />
</Suspense>
</div>
);
}
async function CachedHeader() {
"use cache";
cacheLife('hours');
cacheTag('site-settings');
const settings = await fetch('http://api.cms.com/settings');
return <header>{/* ... */}</header>;
}
async function DynamicUserContent() {
const user = await getCurrentUser();
return <div>{user.notifications}</div>;
}| Content Type | User-Specific? | Update Frequency | Recommended Approach |
|---|---|---|---|
| Marketing pages | No | Rarely | "use cache" + long cacheLife |
| Blog posts | No | Daily/Weekly | "use cache" + cacheTag() |
| Product catalog | No | Hourly | "use cache" + medium cacheLife |
| News feed | No | Minutes | "use cache" + short cacheLife |
| User dashboard | Yes | Per-request | <Suspense> |
| Shopping cart | Yes | Per-request | <Suspense> |
| User settings page | Yes | Occasionally | "use cache: private" |
| Auth-gated content | Yes | Varies | "use cache: private" |
When migrating to Cache Components, new Date() and Math.random() require explicit handling:
Problem: These return different values on every call, creating ambiguity in cached components.
Ask: "Should this value be captured at cache time, or fresh per-request?"
Option 1: Fresh Per-Request (Recommended)
// Use for: timestamps, random IDs, request-specific values
export default async function Page() {
"use cache: private"; // Always fresh, never cached
const timestamp = new Date().toISOString();
return <div>Generated at: {timestamp}</div>;
}Option 2: Captured at Cache Time (With Awareness)
// Use for: "createdAt" timestamps, random seed values that should be stable
export default async function Page() {
"use cache";
cacheLife('days');
// ⚠️ CACHE DECISION: This timestamp is frozen at cache time
// It will stay the same for all users for 24 hours
const generatedAt = new Date().toISOString();
return <div>Generated at: {generatedAt}</div>;
}Option 3: Extract to Separate Dynamic Component
// Best for mixed static + dynamic content
export default async function Page() {
"use cache";
cacheLife('days');
return (
<div>
<MainContent />
<Suspense fallback={<Spinner />}>
<DynamicTimestamp />
</Suspense>
</div>
);
}
async function DynamicTimestamp() {
"use cache: private"; // Always fresh
const timestamp = new Date().toISOString();
return <p>Rendered at: {timestamp}</p>;
}| Pattern | Behavior | Fix |
|---|---|---|
new Date() in cached component |
Frozen at cache time | Add comment explaining tradeoff, or extract to "use cache: private" |
Math.random() for IDs |
Same ID until cache revalidates | Use "use cache: private" if ID should be unique per user/request |
new Date() in SSR function |
Captured at build time | Use await connection() or move to "use cache: private" |
When enabling Cache Components, remove all Route Segment Config exports. IMPORTANT: Capture the original revalidate value and suggest the matching cacheLife profile.
Revalidate → cacheLife Mapping Table:
| Original revalidate | Suggested cacheLife | Profile timing |
|---|---|---|
0 or false |
Dynamic (no "use cache") | Was already dynamic |
60 |
cacheLife('minutes') |
revalidate: 60s |
3600 |
cacheLife('hours') |
revalidate: 3600s (1 hour) |
86400 |
cacheLife('days') |
revalidate: 86400s (1 day) |
604800 |
cacheLife('weeks') |
revalidate: 604800s (1 week) |
| Other values | cacheLife({ revalidate: X }) |
Custom timing |
Example 1: Exact match (revalidate = 3600)
// ❌ BEFORE - Route Segment Config (incompatible with Cache Components)
export const dynamic = 'force-static'
export const revalidate = 3600
export const fetchCache = 'force-cache'
export default async function Page() {
const data = await fetch('https://api.example.com/data')
return <div>{data}</div>
}
// ✅ AFTER - Cache Components approach
// MIGRATED from: export const revalidate = 3600
// → Using cacheLife('hours') to maintain ~1 hour revalidation
import { cacheLife } from 'next/cache'
export default async function Page() {
"use cache"
cacheLife('hours') // Replaces: export const revalidate = 3600
const data = await fetch('https://api.example.com/data')
return <div>{data}</div>
}Example 2: Custom value (revalidate = 1800)
// ❌ BEFORE
export const revalidate = 1800 // 30 minutes
export default async function Page() {
// ...
}
// ✅ AFTER
// MIGRATED from: export const revalidate = 1800 (30 minutes)
// → Using cacheLife({ revalidate: 1800 }) to maintain exact timing
import { cacheLife } from 'next/cache'
export default async function Page() {
"use cache"
cacheLife({ revalidate: 1800 }) // Replaces: export const revalidate = 1800
// ...
}Example 3: Short revalidation (revalidate = 60)
// ❌ BEFORE
export const revalidate = 60 // 1 minute
export default async function Page() {
// ...
}
// ✅ AFTER
// MIGRATED from: export const revalidate = 60
// → Using cacheLife('minutes') to maintain ~60s revalidation
import { cacheLife } from 'next/cache'
export default async function Page() {
"use cache"
cacheLife('minutes') // Replaces: export const revalidate = 60
// ...
}Example 4: Dynamic content (revalidate = 0)
// ❌ BEFORE
export const revalidate = 0 // Always dynamic
export default async function Page() {
// ...
}
// ✅ AFTER
// MIGRATED from: export const revalidate = 0
// → No "use cache" needed - dynamic is now the default with Cache Components
export default async function Page() {
// Dynamic by default - no changes needed
// ...
}