Next.js Cache Components
Auto-activation: This skill activates automatically in projects with
cacheComponents: truein next.config.
Project Detection
When starting work in a Next.js project, check if Cache Components are enabled:
# Check next.config.ts or next.config.js for cacheComponents
grep -r "cacheComponents" next.config.* 2>/dev/null
If cacheComponents: true is found, apply this skill's patterns proactively when:
- Writing React Server Components
- Implementing data fetching
- Creating Server Actions with mutations
- Optimizing page performance
- Reviewing existing component code
Cache Components enable Partial Prerendering (PPR) - mixing static HTML shells with dynamic streaming content for optimal performance.
Philosophy: Code Over Configuration
Cache Components represents a shift from segment configuration to compositional code:
| Before (Deprecated) | After (Cache Components) |
|---|---|
export const revalidate = 3600 |
cacheLife('hours') inside 'use cache' |
export const dynamic = 'force-static' |
Use 'use cache' and Suspense boundaries |
| All-or-nothing static/dynamic | Granular: static shell + cached + dynamic |
Key Principle: Components co-locate their caching, not just their data. Next.js provides build-time feedback to guide you toward optimal patterns.
Core Concept
┌─────────────────────────────────────────────────────┐
│ Static Shell │
│ (Sent immediately to browser) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Header │ │ Cached │ │ Suspense │ │
│ │ (static) │ │ Content │ │ Fallback │ │
│ └─────────────┘ └─────────────┘ └──────┬──────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ Dynamic │ │
│ │ (streams) │ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────┘
Mental Model: The Caching Decision Tree
When writing a React Server Component, ask these questions in order:
┌─────────────────────────────────────────────────────────┐
│ Does this component fetch data or perform I/O? │
└─────────────────────┬───────────────────────────────────┘
│
┌──────────▼──────────┐
│ YES │ NO → Pure component, no action needed
└──────────┬──────────┘
│
┌─────────────────▼─────────────────┐
│ Does it depend on request context? │
│ (cookies, headers, searchParams) │
└─────────────────┬─────────────────┘
│
┌────────────┴────────────┐
│ │
┌────▼────┐ ┌─────▼─────┐
│ YES │ │ NO │
└────┬────┘ └─────┬─────┘
│ │
│ ┌─────▼─────────────────┐
│ │ Can this be cached? │
│ │ (same for all users?) │
│ └─────┬─────────────────┘
│ │
│ ┌──────────┴──────────┐
│ │ │
│ ┌────▼────┐ ┌─────▼─────┐
│ │ YES │ │ NO │
│ └────┬────┘ └─────┬─────┘
│ │ │
│ ▼ │
│ 'use cache' │
│ + cacheTag() │
│ + cacheLife() │
│ │
└──────────────┬─────────────────────┘
│
▼
Wrap in <Suspense>
(dynamic streaming)
Key insight: The 'use cache' directive is for data that's the same across users. User-specific data stays dynamic with Suspense.
Quick Start
Enable Cache Components
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Basic Usage
// Cached component - output included in static shell
async function CachedPosts() {
'use cache'
const posts = await db.posts.findMany()
return <PostList posts={posts} />
}
// Page with static + cached + dynamic content
export default async function BlogPage() {
return (
<>
<Header /> {/* Static */}
<CachedPosts /> {/* Cached */}
<Suspense fallback={<Skeleton />}>
<DynamicComments /> {/* Dynamic - streams */}
</Suspense>
</>
)
}
Core APIs
1. 'use cache' Directive
Marks code as cacheable. Can be applied at three levels:
// File-level: All exports are cached
'use cache'
export async function getData() {
/* ... */
}
export async function Component() {
/* ... */
}
// Component-level
async function UserCard({ id }: { id: string }) {
'use cache'
const user = await fetchUser(id)
return <Card>{user.name}</Card>
}
// Function-level
async function fetchWithCache(url: string) {
'use cache'
return fetch(url).then((r) => r.json())
}
Important: All cached functions must be async.
2. cacheLife() - Control Cache Duration
import { cacheLife } from 'next/cache'
async function Posts() {
'use cache'
cacheLife('hours') // Use a predefined profile
// Or custom configuration:
cacheLife({
stale: 60, // 1 min - client cache validity
revalidate: 3600, // 1 hr - start background refresh
expire: 86400, // 1 day - absolute expiration
})
return await db.posts.findMany()
}
Predefined profiles: 'default', 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max'
3. cacheTag() - Tag for Invalidation
import { cacheTag } from 'next/cache'
async function BlogPosts() {
'use cache'
cacheTag('posts')
cacheLife('days')
return await db.posts.findMany()
}
async function UserProfile({ userId }: { userId: string }) {
'use cache'
cacheTag('users', `user-${userId}`) // Multiple tags
return await db.users.findUnique({ where: { id: userId } })
}
4. updateTag() - Immediate Invalidation
For read-your-own-writes semantics:
'use server'
import { updateTag } from 'next/cache'
export async function createPost(formData: FormData) {
await db.posts.create({ data: formData })
updateTag('posts') // Client immediately sees fresh data
}
5. revalidateTag() - Background Revalidation
For stale-while-revalidate pattern:
'use server'
import { revalidateTag } from 'next/cache'
export async function updatePost(id: string, data: FormData) {
await db.posts.update({ where: { id }, data })
revalidateTag('posts', 'max') // Serve stale, refresh in background
}
When to Use Each Pattern
| Content Type | API | Behavior |
|---|---|---|
| Static | No directive | Rendered at build time |
| Cached | 'use cache' |
Included in static shell, revalidates |
| Dynamic | Inside <Suspense> |
Streams at request time |
Parameter Permutations & Subshells
Critical Concept: With Cache Components, Next.js renders ALL permutations of provided parameters to create reusable subshells.
// app/products/[category]/[slug]/page.tsx
export async function generateStaticParams() {
return [
{ category: 'jackets', slug: 'classic-bomber' },
{ category: 'jackets', slug: 'essential-windbreaker' },
{ category: 'accessories', slug: 'thermal-fleece-gloves' },
]
}
Next.js renders these routes:
/products/jackets/classic-bomber ← Full params (complete page)
/products/jackets/essential-windbreaker ← Full params (complete page)
/products/accessories/thermal-fleece-gloves ← Full params (complete page)
/products/jackets/[slug] ← Partial params (category subshell)
/products/accessories/[slug] ← Partial params (category subshell)
/products/[category]/[slug] ← No params (fallback shell)
Why this matters: The category subshell (/products/jackets/[slug]) can be reused for ANY jacket product, even ones not in generateStaticParams. Users navigating to an unlisted jacket get the cached category shell immediately, with product details streaming in.
generateStaticParams Requirements
With Cache Components enabled:
- Must provide at least one parameter - Empty arrays now cause build errors (prevents silent production failures)
- Params prove static safety - Providing params lets Next.js verify no dynamic APIs are called
- Partial params create subshells - Each unique permutation generates a reusable shell
// ❌ ERROR with Cache Components
export function generateStaticParams() {
return [] // Build error: must provide at least one param
}
// ✅ CORRECT: Provide real params
export async function generateStaticParams() {
const products = await getPopularProducts()
return products.map(({ category, slug }) => ({ category, slug }))
}
Cache Key = Arguments
Arguments become part of the cache key:
// Different userId = different cache entry
async function UserData({ userId }: { userId: string }) {
'use cache'
cacheTag(`user-${userId}`)
return await fetchUser(userId)
}
Build-Time Feedback
Cache Components provides early feedback during development. These build errors guide you toward optimal patterns:
Error: Dynamic data outside Suspense
Error: Accessing cookies/headers/searchParams outside a Suspense boundary
Solution: Wrap dynamic components in <Suspense>:
<Suspense fallback={<Skeleton />}>
<ComponentThatUsesCookies />
</Suspense>
Error: Uncached data outside Suspense
Error: Accessing uncached data outside Suspense
Solution: Either cache the data or wrap in Suspense:
// Option 1: Cache it
async function ProductData({ id }: { id: string }) {
'use cache'
return await db.products.findUnique({ where: { id } })
}
// Option 2: Make it dynamic with Suspense
;<Suspense fallback={<Loading />}>
<DynamicProductData id={id} />
</Suspense>
Error: Request data inside cache
Error: Cannot access cookies/headers inside 'use cache'
Solution: Extract runtime data outside cache boundary (see "Handling Runtime Data" above).
Additional Resources
- For complete API reference, see REFERENCE.md
- For common patterns and recipes, see PATTERNS.md
- For debugging and troubleshooting, see TROUBLESHOOTING.md
Code Generation Guidelines
When generating Cache Component code:
- Always use
async- All cached functions must be async - Place
'use cache'first - Must be first statement in function body - Call
cacheLife()early - Should follow'use cache'directive - Tag meaningfully - Use semantic tags that match your invalidation needs
- Extract runtime data - Move
cookies()/headers()outside cached scope - Wrap dynamic content - Use
<Suspense>for non-cached async components
Proactive Application (When Cache Components Enabled)
When cacheComponents: true is detected in the project, automatically apply these patterns:
When Writing Data Fetching Components
Ask yourself: "Can this data be cached?" If yes, add 'use cache':
// Before: Uncached fetch
async function ProductList() {
const products = await db.products.findMany()
return <Grid products={products} />
}
// After: With caching
async function ProductList() {
'use cache'
cacheTag('products')
cacheLife('hours')
const products = await db.products.findMany()
return <Grid products={products} />
}
When Writing Server Actions
Always invalidate relevant caches after mutations:
'use server'
import { updateTag } from 'next/cache'
export async function createProduct(data: FormData) {
await db.products.create({ data })
updateTag('products') // Don't forget!
}
When Composing Pages
Structure with static shell + cached content + dynamic streaming:
export default async function Page() {
return (
<>
<StaticHeader /> {/* No cache needed */}
<CachedContent /> {/* 'use cache' */}
<Suspense fallback={<Skeleton />}>
<DynamicUserContent /> {/* Streams at runtime */}
</Suspense>
</>
)
}
When Reviewing Code
Flag these issues in Cache Components projects:
- Data fetching without
'use cache'where caching would benefit - Missing
cacheTag()calls (makes invalidation impossible) - Missing
cacheLife()(relies on defaults which may not be appropriate) - Server Actions without
updateTag()/revalidateTag()after mutations -
cookies()/headers()called inside'use cache'scope - Dynamic components without
<Suspense>boundaries - DEPRECATED:
export const revalidate- replace withcacheLife()in'use cache' - DEPRECATED:
export const dynamic- replace with Suspense + cache boundaries - Empty
generateStaticParams()return - must provide at least one param
Patterns
Cache Components Patterns & Recipes
Common patterns for implementing Cache Components effectively.
Pattern 1: Static + Cached + Dynamic Page
The foundational pattern for Partial Prerendering:
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
// Static - no special handling needed
function Header() {
return <header>My Blog</header>
}
// Cached - included in static shell
async function FeaturedPosts() {
'use cache'
cacheLife('hours')
const posts = await db.posts.findMany({
where: { featured: true },
take: 5,
})
return (
<section>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</section>
)
}
// Dynamic - streams at request time
async function PersonalizedFeed() {
const session = await getSession()
const feed = await db.posts.findMany({
where: { authorId: { in: session.following } },
})
return <FeedList posts={feed} />
}
// Page composition
export default async function HomePage() {
return (
<>
<Header />
<FeaturedPosts />
<Suspense fallback={<FeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
</>
)
}
Pattern 2: Read-Your-Own-Writes with Server Actions
Ensure users see their changes immediately:
// components/posts.tsx
import { cacheTag, cacheLife } from 'next/cache'
async function PostsList() {
'use cache'
cacheTag('posts')
cacheLife('hours')
const posts = await db.posts.findMany({ orderBy: { createdAt: 'desc' } })
return (
<ul>
{posts.map((p) => (
<PostItem key={p.id} post={p} />
))}
</ul>
)
}
// actions/posts.ts
'use server'
import { updateTag } from 'next/cache'
export async function createPost(formData: FormData) {
const post = await db.posts.create({
data: {
title: formData.get('title') as string,
content: formData.get('content') as string,
},
})
// Immediate invalidation - user sees new post right away
updateTag('posts')
return { success: true, postId: post.id }
}
// components/create-post-form.tsx
'use client'
import { useTransition } from 'react'
import { createPost } from '@/actions/posts'
export function CreatePostForm() {
const [isPending, startTransition] = useTransition()
return (
<form
action={(formData) => {
startTransition(() => createPost(formData))
}}
>
<input name="title" required />
<textarea name="content" required />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
Pattern 3: Granular Cache Invalidation
Tag caches at multiple levels for precise invalidation:
// Cached with multiple tags
async function BlogPost({ postId }: { postId: string }) {
'use cache'
cacheTag('posts', `post-${postId}`)
cacheLife('days')
const post = await db.posts.findUnique({
where: { id: postId },
include: { author: true, comments: true },
})
return <Article post={post} />
}
async function AuthorPosts({ authorId }: { authorId: string }) {
'use cache'
cacheTag('posts', `author-${authorId}`)
cacheLife('hours')
const posts = await db.posts.findMany({
where: { authorId },
})
return <PostGrid posts={posts} />
}
// Server actions with targeted invalidation
'use server'
import { updateTag } from 'next/cache'
export async function updatePost(postId: string, data: FormData) {
const post = await db.posts.update({
where: { id: postId },
data: { title: data.get('title'), content: data.get('content') },
})
// Invalidate specific post only
updateTag(`post-${postId}`)
}
export async function deleteAuthorPosts(authorId: string) {
await db.posts.deleteMany({ where: { authorId } })
// Invalidate all author's posts
updateTag(`author-${authorId}`)
}
export async function clearAllPosts() {
await db.posts.deleteMany()
// Nuclear option - invalidate everything tagged 'posts'
updateTag('posts')
}
Pattern 4: Cached Data Fetching Functions
Create reusable cached data fetchers:
// lib/data.ts
import { cacheTag, cacheLife } from 'next/cache'
export async function getUser(userId: string) {
'use cache'
cacheTag('users', `user-${userId}`)
cacheLife('hours')
return db.users.findUnique({ where: { id: userId } })
}
export async function getPostsByCategory(category: string) {
'use cache'
cacheTag('posts', `category-${category}`)
cacheLife('minutes')
return db.posts.findMany({
where: { category },
orderBy: { createdAt: 'desc' },
})
}
export async function getPopularProducts() {
'use cache'
cacheTag('products', 'popular')
cacheLife('hours')
return db.products.findMany({
orderBy: { salesCount: 'desc' },
take: 10,
})
}
// Usage in components
async function Sidebar() {
const popular = await getPopularProducts()
return <ProductList products={popular} />
}
Pattern 5: Stale-While-Revalidate for Background Updates
Use revalidateTag for non-critical updates:
// For background analytics or non-user-facing updates
'use server'
import { revalidateTag } from 'next/cache'
export async function trackView(postId: string) {
await db.posts.update({
where: { id: postId },
data: { views: { increment: 1 } },
})
// Background revalidation - old count shown while updating
revalidateTag(`post-${postId}`, 'max')
}
// For user-facing mutations, use updateTag instead
export async function likePost(postId: string) {
await db.likes.create({ data: { postId, userId: getCurrentUserId() } })
// Immediate - user sees their like right away
updateTag(`post-${postId}`)
}
Pattern 6: Conditional Caching Based on Content
Cache based on content characteristics:
async function ContentBlock({ id }: { id: string }) {
'use cache'
const content = await db.content.findUnique({ where: { id } })
// Adjust cache life based on content type
if (content.type === 'static') {
cacheLife('max')
cacheTag('static-content')
} else if (content.type === 'news') {
cacheLife('minutes')
cacheTag('news', `news-${id}`)
} else {
cacheLife('default')
cacheTag('content', `content-${id}`)
}
return <ContentRenderer content={content} />
}
Pattern 7: Nested Cached Components
Compose cached components for fine-grained caching:
// Each component caches independently
async function Header() {
'use cache'
cacheTag('layout', 'header')
cacheLife('days')
const nav = await db.navigation.findFirst()
return <Nav items={nav.items} />
}
async function Footer() {
'use cache'
cacheTag('layout', 'footer')
cacheLife('days')
const footer = await db.footer.findFirst()
return <FooterContent data={footer} />
}
async function Sidebar({ category }: { category: string }) {
'use cache'
cacheTag('sidebar', `category-${category}`)
cacheLife('hours')
const related = await db.posts.findMany({
where: { category },
take: 5,
})
return <RelatedPosts posts={related} />
}
// Page composes cached components
export default async function BlogLayout({
children,
params,
}: {
children: React.ReactNode
params: { category: string }
}) {
return (
<>
<Header />
<main>
{children}
<Sidebar category={params.category} />
</main>
<Footer />
</>
)
}
Pattern 8: E-commerce Product Page
Complete example for e-commerce:
// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { cacheTag, cacheLife } from 'next/cache'
// Cached product details (changes rarely)
async function ProductDetails({ productId }: { productId: string }) {
'use cache'
cacheTag('products', `product-${productId}`)
cacheLife('hours')
const product = await db.products.findUnique({
where: { id: productId },
include: { images: true, specifications: true },
})
return (
<div>
<ProductGallery images={product.images} />
<ProductInfo product={product} />
<Specifications specs={product.specifications} />
</div>
)
}
// Cached reviews (moderate change frequency)
async function ProductReviews({ productId }: { productId: string }) {
'use cache'
cacheTag(`product-${productId}-reviews`)
cacheLife('minutes')
const reviews = await db.reviews.findMany({
where: { productId },
orderBy: { createdAt: 'desc' },
take: 10,
})
return <ReviewsList reviews={reviews} />
}
// Dynamic inventory (real-time)
async function InventoryStatus({ productId }: { productId: string }) {
// No cache - always fresh
const inventory = await db.inventory.findUnique({
where: { productId },
})
return (
<div>
{inventory.quantity > 0 ? (
<span className="text-green-600">In Stock ({inventory.quantity})</span>
) : (
<span className="text-red-600">Out of Stock</span>
)}
</div>
)
}
// Page composition
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
return (
<>
<ProductDetails productId={id} />
<Suspense fallback={<InventorySkeleton />}>
<InventoryStatus productId={id} />
</Suspense>
{/* Suspense around cached components:
- At BUILD TIME (PPR): Cached content is pre-rendered into the static shell,
so the fallback is never shown for initial page loads.
- At RUNTIME (cache miss/expiration): When the cache expires or on cold start,
Suspense shows the fallback while fresh data loads.
- For long-lived caches ('minutes', 'hours', 'days'), Suspense is optional
but improves UX during the rare cache miss. */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
</>
)
}
Pattern 9: Multi-tenant SaaS Application
Handle tenant-specific caching:
// lib/tenant.ts
export async function getTenantId() {
const host = (await headers()).get('host')
return host?.split('.')[0] // subdomain as tenant ID
}
// Tenant-scoped cached data
async function TenantDashboard({ tenantId }: { tenantId: string }) {
'use cache'
cacheTag(`tenant-${tenantId}`, 'dashboards')
cacheLife('minutes')
const data = await db.dashboards.findFirst({
where: { tenantId },
})
return <Dashboard data={data} />
}
// Page with tenant context
export default function DashboardPage() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<DashboardLoader />
</Suspense>
)
}
async function DashboardLoader() {
const tenantId = await getTenantId()
return <TenantDashboard tenantId={tenantId} />
}
// Tenant-specific invalidation
'use server'
import { updateTag } from 'next/cache'
export async function updateTenantSettings(data: FormData) {
const tenantId = await getTenantId()
await db.settings.update({
where: { tenantId },
data: {
/* ... */
},
})
// Only invalidate this tenant's cache
updateTag(`tenant-${tenantId}`)
}
Pattern 10: Subshell Composition with generateStaticParams
Leverage parameter permutations to create reusable subshells:
// app/products/[category]/[slug]/page.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
// Product details - uses both params
async function ProductDetails({
category,
slug,
}: {
category: string
slug: string
}) {
'use cache'
cacheTag('products', `product-${slug}`)
cacheLife('hours')
const product = await db.products.findUnique({
where: { category, slug },
})
return <ProductCard product={product} />
}
export default async function ProductPage({
params,
}: {
params: Promise<{ category: string; slug: string }>
}) {
const { category, slug } = await params
return <ProductDetails category={category} slug={slug} />
}
// Provide params to enable subshell generation
export async function generateStaticParams() {
const products = await db.products.findMany({
select: { category: true, slug: true },
take: 100,
})
return products.map(({ category, slug }) => ({ category, slug }))
}
// app/products/[category]/layout.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
// Category header - uses only category param
async function CategoryHeader({ category }: { category: string }) {
'use cache'
cacheTag('categories', `category-${category}`)
cacheLife('days')
const cat = await db.categories.findUnique({ where: { slug: category } })
return (
<header>
<h1>{cat.name}</h1>
<p>{cat.description}</p>
</header>
)
}
export default async function CategoryLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ category: string }>
}) {
const { category } = await params
return (
<>
<CategoryHeader category={category} />
{/* Suspense enables subshell generation */}
<Suspense fallback={<ProductSkeleton />}>{children}</Suspense>
</>
)
}
Result: When users navigate to /products/jackets/unknown-jacket:
- Category subshell (
/products/jackets/[slug]) served instantly - Product details stream in as they load
- Future visits to any jacket product reuse the category shell
Pattern 11: Hierarchical Params for Deep Routes
For deeply nested routes, structure layouts to maximize subshell reuse:
// Route: /store/[region]/[category]/[productId]
// app/store/[region]/layout.tsx
export default async function RegionLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ region: string }>
}) {
const { region } = await params
return (
<>
<RegionHeader region={region} /> {/* Cached */}
<RegionPromos region={region} /> {/* Cached */}
<Suspense>{children}</Suspense> {/* Subshell boundary */}
</>
)
}
// app/store/[region]/[category]/layout.tsx
export default async function CategoryLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ region: string; category: string }>
}) {
const { region, category } = await params
return (
<>
<CategoryNav region={region} category={category} /> {/* Cached */}
<Suspense>{children}</Suspense> {/* Subshell boundary */}
</>
)
}
// app/store/[region]/[category]/[productId]/page.tsx
export default async function ProductPage({
params,
}: {
params: Promise<{ region: string; category: string; productId: string }>
}) {
const { region, category, productId } = await params
return <ProductDetails region={region} productId={productId} />
}
export async function generateStaticParams() {
// Return popular products - subshells generated for all unique region/category combos
return [
{ region: 'us', category: 'electronics', productId: 'iphone-16' },
{ region: 'us', category: 'electronics', productId: 'macbook-pro' },
{ region: 'us', category: 'clothing', productId: 'hoodie-xl' },
{ region: 'eu', category: 'electronics', productId: 'iphone-16' },
]
}
Generated subshells:
/store/us/[category]/[productId]- US region shell/store/eu/[category]/[productId]- EU region shell/store/us/electronics/[productId]- US Electronics shell/store/us/clothing/[productId]- US Clothing shell/store/eu/electronics/[productId]- EU Electronics shell
When to Use Suspense with Cached Components
Understanding when Suspense is required vs. optional for cached components:
Dynamic Components (no cache) → Suspense Required
// Dynamic content MUST have Suspense for streaming
async function PersonalizedFeed() {
const session = await getSession() // Dynamic - reads cookies
const feed = await fetchFeed(session.userId)
return <Feed posts={feed} />
}
export default function Page() {
return (
<Suspense fallback={<FeedSkeleton />}>
<PersonalizedFeed />
</Suspense>
)
}
Cached Components → Suspense Optional (but recommended)
// Cached content: Suspense is optional but improves UX
async function ProductReviews({ productId }: { productId: string }) {
'use cache'
cacheLife('minutes')
const reviews = await fetchReviews(productId)
return <ReviewsList reviews={reviews} />
}
// ✅ With Suspense - handles cache miss gracefully
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={id} />
</Suspense>
// ✅ Without Suspense - also valid for long-lived caches
<ProductReviews productId={id} />
Why Cached Components Don't Always Need Suspense
| Scenario | What Happens | Suspense Needed? |
|---|---|---|
| Build time (PPR enabled) | Content pre-rendered into static shell | No - fallback never shown |
| Runtime - cache hit | Cached result returned immediately | No - no suspension |
| Runtime - cache miss | Async function executes, component suspends | Yes - for better UX |
Recommendations by Cache Lifetime
| Cache Lifetime | Suspense Recommendation | Reasoning |
|---|---|---|
'seconds' |
Recommended | Frequent cache misses |
'minutes' |
Optional | ~5 min expiry, occasional misses |
'hours' / 'days' |
Optional | Rare cache misses |
'max' |
Not needed | Essentially static |
The Trade-off
Without Suspense: On cache miss, the page waits for data before rendering anything downstream. For long-lived caches, this is rare and brief.
With Suspense: On cache miss, users see the skeleton immediately while data loads. Better perceived performance, slightly more code.
Rule of thumb: When in doubt, add Suspense. It never hurts and handles edge cases gracefully.
Anti-Patterns to Avoid
❌ Caching user-specific data without parameters
// BAD: Same cache for all users
async function UserProfile() {
'use cache'
const user = await getCurrentUser() // Different per user!
return <Profile user={user} />
}
// GOOD: User ID as parameter (becomes cache key)
async function UserProfile({ userId }: { userId: string }) {
'use cache'
cacheTag(`user-${userId}`)
const user = await db.users.findUnique({ where: { id: userId } })
return <Profile user={user} />
}
❌ Over-caching volatile data
// BAD: Caching real-time data
async function StockPrice({ symbol }: { symbol: string }) {
'use cache'
cacheLife('hours') // Stale prices!
return await fetchStockPrice(symbol)
}
// GOOD: Don't cache, or use very short cache
async function StockPrice({ symbol }: { symbol: string }) {
'use cache'
cacheLife('seconds') // 1 second max
return await fetchStockPrice(symbol)
}
// BETTER: No cache for truly real-time
async function StockPrice({ symbol }: { symbol: string }) {
return await fetchStockPrice(symbol)
}
❌ Forgetting Suspense for dynamic content
// BAD: No fallback for DYNAMIC content - breaks streaming
export default async function Page() {
return (
<>
<CachedHeader />
<DynamicContent /> {/* Dynamic - NEEDS Suspense */}
</>
)
}
// GOOD: Proper Suspense boundary for dynamic content
export default async function Page() {
return (
<>
<CachedHeader />
<Suspense fallback={<ContentSkeleton />}>
<DynamicContent />
</Suspense>
</>
)
}
// ALSO GOOD: Cached content without Suspense (optional for long-lived caches)
export default async function Page() {
return (
<>
<CachedHeader /> {/* 'use cache' - no Suspense needed */}
<CachedSidebar /> {/* 'use cache' - no Suspense needed */}
<Suspense fallback={<ContentSkeleton />}>
<DynamicContent /> {/* Dynamic - Suspense required */}
</Suspense>
</>
)
}
API Reference
Cache Components API Reference
Complete API reference for Next.js Cache Components.
Directive: 'use cache'
Marks a function or file as cacheable. The cached output is included in the static shell during Partial Prerendering.
Syntax
// File-level (applies to all exports)
'use cache'
export async function getData() {
/* ... */
}
// Function-level
async function Component() {
'use cache'
// ...
}
Variants
| Directive | Description | Cache Storage |
|---|---|---|
'use cache' |
Standard cache (default) | Default handler + Remote |
'use cache: remote' |
Platform remote cache | Remote handler only |
'use cache: remote'
Uses platform-specific remote cache handler. Requires network roundtrip.
async function HeavyComputation() {
'use cache: remote'
cacheLife('days')
return await expensiveCalculation()
}
Understanding Cache Handlers
Next.js uses cache handlers to store and retrieve cached data. The directive variant determines which handlers are used:
| Handler | Description |
|---|---|
default |
Local in-memory cache with optional persistence. Fast, single-server scope |
remote |
Platform-specific distributed cache. Network roundtrip, multi-server scope |
How variants map to handlers:
'use cache'→ Uses both default and remote handlers. Data is cached locally for fast access and remotely for sharing across instances'use cache: remote'→ Uses only the remote handler. Skips local cache, always fetches from distributed cache
When to use each:
| Use Case | Recommended Variant |
|---|---|
| Most cached data | 'use cache' |
| Heavy computations to share globally | 'use cache: remote' |
| Data that must be consistent globally | 'use cache: remote' |
Rules
- Must be async - All cached functions must return a Promise
- First statement -
'use cache'must be the first statement in the function body - No runtime APIs - Cannot call
cookies(),headers(),searchParamsdirectly - Serializable arguments - All arguments must be serializable (no functions, class instances)
- Serializable return values - Cached functions must return serializable data (no functions, class instances)
Function: cacheLife()
Configures cache duration and revalidation behavior.
Import
import { cacheLife } from 'next/cache'
Signature
function cacheLife(profile: string): void
function cacheLife(options: CacheLifeOptions): void
interface CacheLifeOptions {
stale?: number // Client cache duration (seconds)
revalidate?: number // Background revalidation window (seconds)
expire?: number // Absolute expiration (seconds)
}
Parameters
| Parameter | Description | Constraint |
|---|---|---|
stale |
How long the client can cache without server validation | None |
revalidate |
When to start background refresh | revalidate ≤ expire |
expire |
Absolute expiration; deopts to dynamic if exceeded | Must be largest |
Predefined Profiles
| Profile | stale | revalidate | expire |
|---|---|---|---|
'default' |
300* | 900 (15min) | ∞ (INFINITE) |
'seconds' |
30 | 1 | 60 |
'minutes' |
300 | 60 (1min) | 3600 (1hr) |
'hours' |
300 | 3600 (1hr) | 86400 (1day) |
'days' |
300 | 86400 (1day) | 604800 (1wk) |
'weeks' |
300 | 604800 (1wk) | 2592000 (30d) |
'max' |
300 | 2592000 (30d) | 31536000 (1yr) |
* Default stale falls back to experimental.staleTimes.static (300 seconds)
Important: Profiles with
expire < 300seconds (like'seconds') are treated as dynamic and won't be included in the static shell during Partial Prerendering. See Dynamic Threshold below.
Custom Profiles
Define custom profiles in next.config.ts:
const nextConfig: NextConfig = {
cacheLife: {
// Custom profile
'blog-posts': {
stale: 300, // 5 minutes
revalidate: 3600, // 1 hour
expire: 86400, // 1 day
},
// Override default
default: {
stale: 60,
revalidate: 600,
expire: 3600,
},
},
}
Usage
async function BlogPosts() {
'use cache'
cacheLife('blog-posts') // Custom profile
return await db.posts.findMany()
}
HTTP Cache-Control Mapping
stale → max-age
revalidate → s-maxage
expire - revalidate → stale-while-revalidate
Example: stale=60, revalidate=3600, expire=86400
→ Cache-Control: max-age=60, s-maxage=3600, stale-while-revalidate=82800
Dynamic Threshold
Cache entries with short expiration times are treated as dynamic holes during Partial Prerendering:
| Condition | Behavior |
|---|---|
expire < 300 seconds |
Treated as dynamic (not in static shell) |
revalidate === 0 |
Treated as dynamic (not in static shell) |
expire >= 300 seconds |
Included in static shell |
Why expire, not stale?
The threshold uses expire (absolute expiration) because:
expiredefines the maximum lifetime of the cache entry- If
expireis very short, the cached content would immediately become invalid in the static shell staleonly affects client-side freshness perception - how long before the browser revalidates- Including short-lived content in the static shell would serve guaranteed-stale data
Practical implications:
cacheLife('seconds')(expire=60) → Dynamic - streams at request timecacheLife('minutes')(expire=3600) → Static - included in PPR shell- Custom
cacheLife({ expire: 120 })→ Dynamic - below 300s threshold
This 300-second threshold ensures that very short-lived caches don't pollute the static shell with immediately-stale content.
// This cache is DYNAMIC (expire=60 < 300)
async function RealtimePrice() {
'use cache'
cacheLife('seconds') // expire=60, below threshold
return await fetchPrice()
}
// This cache is STATIC (expire=3600 >= 300)
async function ProductDetails() {
'use cache'
cacheLife('minutes') // expire=3600, above threshold
return await fetchProduct()
}
Function: cacheTag()
Tags cached data for targeted invalidation.
Import
import { cacheTag } from 'next/cache'
Signature
function cacheTag(...tags: string[]): void
Usage
async function UserProfile({ userId }: { userId: string }) {
'use cache'
cacheTag('users', `user-${userId}`) // Multiple tags
cacheLife('hours')
return await db.users.findUnique({ where: { id: userId } })
}
Tagging Strategies
Entity-based tagging:
cacheTag('posts') // All posts
cacheTag(`post-${postId}`) // Specific post
cacheTag(`user-${userId}-posts`) // User's posts
Feature-based tagging:
cacheTag('homepage')
cacheTag('dashboard')
cacheTag('admin')
Combined approach:
cacheTag('posts', `post-${id}`, `author-${authorId}`)
Tag Constraints
Tags have enforced limits:
| Limit | Value | Behavior if exceeded |
|---|---|---|
| Max tag length | 256 characters | Warning logged, tag ignored |
| Max total tags | 128 tags | Warning logged, excess ignored |
// ❌ Tag too long (>256 chars) - will be ignored with warning
cacheTag('a'.repeat(300))
// ❌ Too many tags (>128) - excess will be ignored with warning
cacheTag(...Array(200).fill('tag'))
// ✅ Valid usage
cacheTag('products', `product-${id}`, `category-${category}`)
Implicit Tags (Automatic)
In addition to explicit cacheTag() calls, Next.js automatically applies implicit tags based on the route hierarchy. This means revalidatePath() works without any explicit cacheTag() calls:
'use server'
import { revalidatePath } from 'next/cache'
export async function publishBlogPost() {
await db.posts.create({
/* ... */
})
// Works without explicit cacheTag() - uses implicit route-based tags
revalidatePath('/blog', 'layout') // Invalidates all /blog/* routes
}
How it works:
- Each route segment (layout, page) automatically receives an internal tag
revalidatePath('/blog', 'layout')invalidates the/bloglayout and all nested routesrevalidatePath('/blog/my-post')invalidates only that specific page
Choosing between implicit and explicit tags:
| Use Case | Approach |
|---|---|
| Invalidate all cached data under a route | revalidatePath() (uses implicit) |
| Invalidate specific entity across routes | cacheTag() + updateTag() |
| User needs to see their change (eager) | updateTag() with explicit tag |
| Background update, eventual OK (lazy) | revalidateTag() with explicit tag |
Understanding Cache Scope
What Creates a New Cache Entry?
A new cache entry is created when ANY of these differ:
| Factor | Example |
|---|---|
| Function identity | Different functions = different entries |
| Arguments | getUser("123") vs getUser("456") |
| File path | Same function name in different files |
Cache Key Composition
Cache keys are composed of multiple parts:
[buildId, functionId, serializedArgs, (hmrRefreshHash)]
| Part | Description |
|---|---|
buildId |
Unique build identifier (prevents cross-deployment cache reuse) |
functionId |
Server reference ID for the cached function |
serializedArgs |
React Flight-encoded function arguments |
hmrRefreshHash |
(Dev only) Invalidates cache on file changes |
// These create TWO separate cache entries (third call is a cache hit):
async function getProduct(id: string) {
'use cache'
return db.products.findUnique({ where: { id } })
}
await getProduct('prod-1') // Cache entry 1: [buildId, getProduct, "prod-1"]
await getProduct('prod-2') // Cache entry 2: [buildId, getProduct, "prod-2"]
await getProduct('prod-1') // Cache HIT on entry 1
Object Arguments and Cache Keys
Arguments are serialized using React's encodeReply(), which performs structural serialization:
async function getData(options: { limit: number }) {
'use cache'
return fetch(`/api?limit=${options.limit}`)
}
// Objects with identical structure produce the same cache key
getData({ limit: 10 }) // Cache key includes serialized { limit: 10 }
getData({ limit: 10 }) // HIT! Same structural content
// Different values = different cache keys
getData({ limit: 20 }) // MISS - different content
Best practice: While objects work correctly, primitives are simpler to reason about:
// ✅ Clear and explicit
async function getData(limit: number) {
'use cache'
return fetch(`/api?limit=${limit}`)
}
Note: Non-serializable values (functions, class instances, Symbols) cannot be used as arguments to cached functions and will cause errors.
Function: updateTag()
Immediately invalidates cache entries and ensures read-your-own-writes.
Import
import { updateTag } from 'next/cache'
Signature
function updateTag(tag: string): void
Usage
'use server'
import { updateTag } from 'next/cache'
export async function createPost(formData: FormData) {
const post = await db.posts.create({ data: formData })
updateTag('posts') // Update all cache entries tagged with 'posts'
updateTag(`user-${userId}`) // Update all cache entries tagged with this user
// Client immediately sees fresh data
}
Behavior
- Immediate: Cache invalidated synchronously
- Read-your-own-writes: Subsequent reads return fresh data
- Server Actions only: Must be called from Server Actions
Function: revalidateTag()
Marks cache entries as stale for background revalidation.
Import
import { revalidateTag } from 'next/cache'
Signature
function revalidateTag(tag: string, profile: string | { expire?: number }): void
Parameters
| Parameter | Type | Description |
|---|---|---|
tag |
string |
The cache tag to invalidate |
profile |
string | { expire?: number } |
Cache profile name or object with expire time (seconds) |
Note: Unlike
cacheLife()which acceptsstale,revalidate, andexpire, therevalidateTag()object form only acceptsexpire. Use a predefined profile name (like'hours') for full control over stale-while-revalidate behavior.
Usage
'use server'
import { revalidateTag } from 'next/cache'
export async function updateSettings(data: FormData) {
await db.settings.update({ data })
// With predefined profile (recommended)
revalidateTag('settings', 'hours')
// With custom expiration
revalidateTag('settings', { expire: 3600 })
}
Behavior
- Stale-while-revalidate: Serves cached content while refreshing in background
- Background refresh: Cache entry is refreshed in the background after the next visit
- Broader context: Can be called from Route Handlers and Server Actions
updateTag() vs revalidateTag(): When to Use Each
The key distinction is eager vs lazy invalidation:
updateTag()- Eager invalidation. Cache is immediately invalidated, and the next read fetches fresh data synchronously. Use when the user who triggered the action needs to see the result.revalidateTag()- Lazy (SWR-style) invalidation. Stale data may be served while fresh data is fetched in the background. Use when eventual consistency is acceptable.
Here's a decision guide:
| Scenario | Use | Why |
|---|---|---|
| User creates a post | updateTag() |
User expects to see their post immediately |
| User updates their profile | updateTag() |
Read-your-own-writes semantics |
| Admin publishes content | revalidateTag() |
Other users can see stale briefly |
| Analytics/view counts | revalidateTag() |
Freshness less critical |
| Background sync job | revalidateTag() |
No user waiting for result |
| E-commerce cart update | updateTag() |
User needs accurate cart state |
E-commerce Example
'use server'
import { updateTag, revalidateTag } from 'next/cache'
// When USER adds to cart → updateTag (they need accurate count)
export async function addToCart(productId: string, userId: string) {
await db.cart.add({ productId, userId })
updateTag(`cart-${userId}`) // Immediate - user sees their cart
}
// When INVENTORY changes from warehouse sync → revalidateTag
export async function syncInventory(products: Product[]) {
await db.inventory.bulkUpdate(products)
revalidateTag('inventory', 'max') // Background - eventual consistency OK
}
// When USER completes purchase → updateTag for buyer, revalidateTag for product
export async function completePurchase(orderId: string) {
const order = await processOrder(orderId)
updateTag(`order-${orderId}`) // Buyer sees confirmation immediately
updateTag(`cart-${order.userId}`) // Buyer's cart clears immediately
revalidateTag(`product-${order.productId}`, 'max') // Others see updated stock eventually
}
The Rule of Thumb
updateTag: "The person who triggered this action is waiting to see the result"
revalidateTag: "This update affects others, but they don't know to wait for it"
Function: revalidatePath()
Revalidates all cache entries associated with a path.
Import
import { revalidatePath } from 'next/cache'
Signature
function revalidatePath(path: string, type?: 'page' | 'layout'): void
Usage
'use server'
import { revalidatePath } from 'next/cache'
export async function updateBlog() {
await db.posts.update({
/* ... */
})
revalidatePath('/blog') // Specific path
revalidatePath('/blog', 'layout') // Layout and all children
revalidatePath('/', 'layout') // Entire app
}
Configuration: next.config.ts
Enable Cache Components
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
Configure Cache Handlers
const nextConfig: NextConfig = {
cacheHandlers: {
default: {
maxMemorySize: 52428800, // 50MB
},
// Platform-specific remote handler
remote: CustomRemoteHandler,
},
}
Define Cache Profiles
const nextConfig: NextConfig = {
cacheLife: {
default: {
stale: 60,
revalidate: 3600,
expire: 86400,
},
posts: {
stale: 300,
revalidate: 3600,
expire: 604800,
},
},
}
generateStaticParams with Cache Components
When Cache Components is enabled, generateStaticParams behavior changes significantly.
Parameter Permutation Rendering
Next.js renders ALL permutations of provided parameters to create reusable subshells:
// app/products/[category]/[slug]/page.tsx
export async function generateStaticParams() {
return [
{ category: 'jackets', slug: 'bomber' },
{ category: 'jackets', slug: 'parka' },
{ category: 'shoes', slug: 'sneakers' },
]
}
Rendered routes:
| Route | Params Known | Shell Type |
|---|---|---|
/products/jackets/bomber |
category ✓, slug ✓ | Complete page |
/products/jackets/parka |
category ✓, slug ✓ | Complete page |
/products/shoes/sneakers |
category ✓, slug ✓ | Complete page |
/products/jackets/[slug] |
category ✓, slug ✗ | Category subshell |
/products/shoes/[slug] |
category ✓, slug ✗ | Category subshell |
/products/[category]/[slug] |
category ✗, slug ✗ | Fallback shell |
Requirements
- Must return at least one parameter set - Empty arrays cause build errors
- Params validate static safety - Next.js uses provided params to verify no dynamic APIs are accessed
- Subshells require Suspense - If accessing unknown params without Suspense, no subshell is generated
// ❌ BUILD ERROR: Empty array not allowed
export function generateStaticParams() {
return []
}
// ✅ CORRECT: Provide at least one param set
export async function generateStaticParams() {
const products = await getProducts({ limit: 100 })
return products.map((p) => ({ category: p.category, slug: p.slug }))
}
Subshell Generation with Layouts
Create category-level subshells by adding Suspense in layouts:
// app/products/[category]/layout.tsx
export default async function CategoryLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ category: string }>
}) {
const { category } = await params
return (
<>
<h2>{category}</h2>
<Suspense>{children}</Suspense> {/* Creates subshell boundary */}
</>
)
}
Now /products/jackets/[slug] generates a reusable shell with the category header, streaming product details when visited.
Why Subshells Matter
Without generateStaticParams, visiting /products/jackets/unknown-product:
- Before: Full dynamic render, user waits for everything
- After: Cached category subshell served instantly, product details stream in
Deprecated Segment Configurations
These exports are deprecated when cacheComponents: true:
export const revalidate (Deprecated)
Before:
// app/products/page.tsx
export const revalidate = 3600 // 1 hour
export default async function ProductsPage() {
const products = await db.products.findMany()
return <ProductList products={products} />
}
Problems with this approach:
- Revalidation time lived at segment level, not with the data
- Couldn't vary revalidation based on fetched data
- No control over client-side caching (
stale) or expiration
After (Cache Components):
// app/products/page.tsx
import { cacheLife } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('hours') // Co-located with the data
return await db.products.findMany()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductList products={products} />
}
Benefits:
- Cache lifetime co-located with data fetching
- Granular control:
stale,revalidate, andexpire - Different functions can have different lifetimes
- Can conditionally set cache life based on data
export const dynamic (Deprecated)
Before:
// app/products/page.tsx
export const dynamic = 'force-static'
export default async function ProductsPage() {
// Headers would return empty, silently breaking components
const headers = await getHeaders()
return <ProductList />
}
Problems:
- All-or-nothing approach
force-staticsilently broke dynamic APIs (cookies, headers return empty)force-dynamicprevented any static optimization- Hidden bugs when dynamic components received empty data
After (Cache Components):
// app/products/page.tsx
export default async function ProductsPage() {
return (
<>
<CachedProductList /> {/* Static via 'use cache' */}
<Suspense fallback={<Skeleton />}>
<DynamicUserRecommendations /> {/* Dynamic via Suspense */}
</Suspense>
</>
)
}
Benefits:
- No silent API failures
- Granular static/dynamic at component level
- Build errors guide you to correct patterns
- Pages can be BOTH static AND dynamic
Migration Guide
| Old Pattern | New Pattern |
|---|---|
export const revalidate = 60 |
cacheLife({ revalidate: 60 }) inside 'use cache' |
export const revalidate = 0 |
Remove cache or use cacheLife('seconds') |
export const revalidate = false |
cacheLife('max') for long-term caching |
export const dynamic = 'force-static' |
Use 'use cache' on data fetching |
export const dynamic = 'force-dynamic' |
Wrap in <Suspense> without cache |
export const dynamic = 'auto' |
Default behavior - not needed |
export const dynamic = 'error' |
Default with Cache Components (build errors guide you) |
Migration Scenarios
Scenario 1: Page with revalidate Export
Before:
// app/products/page.tsx
export const revalidate = 3600
export default async function ProductsPage() {
const products = await db.products.findMany()
return <ProductGrid products={products} />
}
After:
// app/products/page.tsx
import { cacheLife } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('hours') // Roughly equivalent to revalidate = 3600
return db.products.findMany()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductGrid products={products} />
}
Scenario 2: Page with dynamic = 'force-dynamic'
Before:
// app/dashboard/page.tsx
export const dynamic = 'force-dynamic'
export default async function Dashboard() {
const user = await getCurrentUser()
const stats = await getStats()
const notifications = await getNotifications(user.id)
return (
<div>
<UserHeader user={user} />
<Stats data={stats} />
<Notifications items={notifications} />
</div>
)
}
After:
// app/dashboard/page.tsx
import { Suspense } from 'react'
// All data is dynamic - fetches user-specific content
async function DashboardContent() {
const user = await getCurrentUser()
const stats = await getStats()
const notifications = await getNotifications(user.id)
return (
<>
<UserHeader user={user} />
<Stats data={stats} />
<Notifications items={notifications} />
</>
)
}
export default function Dashboard() {
return (
<div>
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent /> {/* Streams dynamically */}
</Suspense>
</div>
)
}
Key difference: No export const dynamic needed. Components are dynamic by default - just wrap in Suspense to enable streaming.
Scenario 3: ISR with revalidate + On-Demand Revalidation
Before:
// app/blog/[slug]/page.tsx
export const revalidate = 3600
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return <Article post={post} />
}
// api/revalidate/route.ts
export async function POST(request: Request) {
const { slug } = await request.json()
revalidatePath(`/blog/${slug}`)
return Response.json({ revalidated: true })
}
After:
// lib/posts.ts
import { cacheTag, cacheLife } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheTag('posts', `post-${slug}`)
cacheLife('hours')
return db.posts.findUnique({ where: { slug } })
}
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return <Article post={post} />
}
// app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
export async function POST(request: Request) {
const { slug } = await request.json()
revalidatePath(`/blog/${slug}`)
return Response.json({ revalidated: true })
}
Key improvements:
- Cache configuration co-located with data fetching via
'use cache' - Explicit cache tags enable targeted invalidation
- Route Handler pattern preserved for external webhook integration
Runtime Behaviors
Draft Mode
When Draft Mode is enabled, cache entries are not saved:
import { draftMode } from 'next/headers'
export default async function PreviewPage() {
const { isEnabled } = await draftMode()
// When isEnabled is true:
// - 'use cache' functions still execute
// - But results are NOT stored in cache
// - Ensures preview content is always fresh
}
This prevents stale preview content from being cached and served to production users.
Cache Bypass Conditions
Cache is bypassed (not read from) when:
| Condition | Description |
|---|---|
| Draft Mode enabled | draftMode().isEnabled === true |
| On-demand revalidation | revalidateTag() or revalidatePath() was called |
| Dev mode + no-cache | Request includes Cache-Control: no-cache header |
Prerender Timeout
During static prerendering (build time), cached functions have a 50-second timeout:
- If a cached function doesn't complete within 50 seconds, it becomes a dynamic hole
- At request time, there is no timeout - background revalidation can take as long as needed
- Timeout errors throw
UseCacheTimeoutErrorwith code'USE_CACHE_TIMEOUT'
// If this takes >50s during build, it becomes dynamic
async function SlowData() {
'use cache'
return await verySlowApiCall() // May timeout during prerender
}
Development Mode: HMR Cache Invalidation
In development, cache keys include an HMR refresh hash:
- When you edit a file containing a cached function, the cache automatically invalidates
- No manual cache clearing needed during development
- This hash is not included in production builds
Cache Propagation (Nested Caches)
When cached functions call other cached functions, cache metadata propagates upward:
async function Inner() {
'use cache'
cacheLife('seconds') // expire=60
cacheTag('inner')
return await fetchData()
}
async function Outer() {
'use cache'
cacheLife('hours') // expire=86400
cacheTag('outer')
const data = await Inner() // Calls inner cached function
return process(data)
}
// Outer's effective cache:
// - expire = min(86400, 60) = 60 (inherits Inner's shorter expiration)
// - tags = ['outer', 'inner'] (tags merge)
This ensures parent caches don't outlive their dependencies.
Type Definitions
CacheLife
type CacheLife = {
stale?: number // Default: 300 (from staleTimes.static)
revalidate?: number // Default: profile-dependent
expire?: number // Default: profile-dependent
}
CacheLifeProfile
type CacheLifeProfile =
| 'default'
| 'seconds'
| 'minutes'
| 'hours'
| 'days'
| 'weeks'
| 'max'
| string // Custom profiles
Troubleshooting
Cache Components Troubleshooting
Common issues, debugging techniques, and solutions for Cache Components.
Build-Time Feedback Philosophy
Cache Components introduces early feedback during development. Unlike before where errors might only appear in production, Cache Components produces build errors that guide you toward optimal patterns.
Key principle: If it builds, it's correct. The build process validates that:
- Dynamic data isn't accessed outside Suspense boundaries
- Cached data doesn't depend on request-specific APIs
generateStaticParamsprovides valid parameters to test rendering
Quick Debugging Checklist
Copy this checklist when debugging cache issues:
Cache Not Working
-
cacheComponents: truein next.config? - Function is
async? -
'use cache'is FIRST statement in function body? - All arguments are serializable (no functions, class instances)?
- Not accessing
cookies()/headers()inside cache?
Stale Data After Mutation
- Called
updateTag()orrevalidateTag()after mutation? - Tag in invalidation matches tag in
cacheTag()? - Using
updateTag()(notrevalidateTag()) for immediate updates?
Build Errors
- Dynamic data wrapped in
<Suspense>? -
generateStaticParamsreturns at least one param? - Not mixing
'use cache'withcookies()/headers()?
Performance Issues
- Cache granularity appropriate? (not too coarse/fine)
-
cacheLifeset appropriately for data volatility? - Using hierarchical tags for targeted invalidation?
Error: UseCacheTimeoutError
Symptoms
Error: A component used 'use cache' but didn't complete within 50 seconds.
Cause
The cached function is accessing request-specific data (cookies, headers, searchParams) or making requests that depend on runtime context.
Solution
User-specific content that depends on runtime data (cookies, headers, searchParams) should not be cached. Instead, stream it dynamically:
// ❌ WRONG: Trying to cache user-specific content
async function UserContent() {
'use cache'
const session = await cookies() // Causes timeout!
return await fetchContent(session.userId)
}
// ✅ CORRECT: Don't cache user-specific content, stream it instead
async function UserContent() {
const session = await cookies()
return await fetchContent(session.get('userId')?.value)
}
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<UserContent /> {/* No 'use cache' - streams dynamically */}
</Suspense>
)
}
Key insight: Cache Components are for content that can be shared across users (e.g., product details, blog posts). User-specific content should stream at request time.
Error: Cannot use 'use cache' with sync function
Symptoms
Error: 'use cache' can only be used in async functions
Cause
Cache Components require async functions because cached outputs are streamed.
Solution
// ❌ WRONG: Synchronous function
function CachedComponent() {
'use cache'
return <div>Hello</div>
}
// ✅ CORRECT: Async function
async function CachedComponent() {
'use cache'
return <div>Hello</div>
}
Error: Dynamic Data Outside Suspense
Symptoms
Error: Accessing cookies/headers/searchParams outside a Suspense boundary
Cause
With Cache Components, accessing request-specific APIs (cookies, headers, searchParams, connection) requires a Suspense boundary so Next.js can provide a static fallback.
Why This Changed
Before Cache Components: The page silently became fully dynamic - no static content served.
After Cache Components: Build error ensures you explicitly handle the dynamic boundary.
Solution
Wrap dynamic content in Suspense:
// ❌ ERROR: No Suspense boundary
export default async function Page() {
return (
<>
<Header />
<UserDeals /> {/* Uses cookies() */}
</>
)
}
// ✅ CORRECT: Suspense provides static fallback
export default async function Page() {
return (
<>
<Header />
<Suspense fallback={<DealsSkeleton />}>
<UserDeals />
</Suspense>
</>
)
}
See also: Pattern 1 (Static + Cached + Dynamic Page) in PATTERNS.md shows the foundational Suspense boundary pattern.
Error: Uncached Data Outside Suspense
Symptoms
Error: Accessing uncached data outside Suspense
Cause
With Cache Components, ALL async I/O is considered dynamic by default. Database queries, fetch calls, and file reads must either be cached or wrapped in Suspense.
Note on synchronous databases: Libraries with synchronous APIs (e.g.,
better-sqlite3) don't trigger this error because they don't involve async I/O. Synchronous operations complete during render and are included in the static shell. However, this also means they block the render thread - use judiciously for small, fast queries only.
Solution
Either cache the data or wrap in Suspense:
// ❌ ERROR: Uncached database query without Suspense
export default async function ProductPage({ params }) {
const product = await db.products.findUnique({ where: { id: params.id } })
return <ProductCard product={product} />
}
// ✅ OPTION 1: Cache the data
async function getProduct(id: string) {
'use cache'
cacheTag(`product-${id}`)
cacheLife('hours')
return await db.products.findUnique({ where: { id } })
}
export default async function ProductPage({ params }) {
const product = await getProduct(params.id)
return <ProductCard product={product} />
}
// ✅ OPTION 2: Wrap in Suspense (streams dynamically)
export default async function ProductPage({ params }) {
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductContent id={params.id} />
</Suspense>
)
}
See also: Pattern 5 (Cached Data Fetching Functions) in PATTERNS.md shows reusable cached data fetcher patterns.
Error: Empty generateStaticParams
Symptoms
Error: generateStaticParams must return at least one parameter set
Cause
With Cache Components, empty generateStaticParams is no longer allowed. This prevents a class of bugs where dynamic API usage in components would only error in production.
Why This Changed
Before: Empty array = "trust me, this is static". Dynamic API usage in production caused runtime errors.
After: Must provide at least one param set so Next.js can validate the page actually renders statically.
Solution
// ❌ ERROR: Empty array
export function generateStaticParams() {
return []
}
// ✅ CORRECT: Provide at least one param
export async function generateStaticParams() {
const products = await getPopularProducts()
return products.map(({ category, slug }) => ({ category, slug }))
}
// ✅ ALSO CORRECT: Hardcoded for known routes
export function generateStaticParams() {
return [{ slug: 'about' }, { slug: 'contact' }, { slug: 'pricing' }]
}
Error: Request Data Inside Cache
Symptoms
Error: Cannot access cookies/headers inside 'use cache'
Cause
Cache contexts cannot depend on request-specific data because the cached result would be shared across all users.
Solution
User-specific content should not be cached. Remove 'use cache' and stream the content dynamically:
// ❌ ERROR: Cookies inside cache
async function UserDashboard() {
'use cache'
const session = await cookies() // Error!
return await fetchDashboard(session.get('userId'))
}
// ✅ CORRECT: Don't cache user-specific content
async function UserDashboard() {
const session = await cookies()
return await fetchDashboard(session.get('userId')?.value)
}
export default function Page() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserDashboard /> {/* Streams at request time */}
</Suspense>
)
}
Key insight: Cache Components are for content that can be shared across users. User-specific dashboards should stream dynamically.
Issue: Cache Not Being Used
Symptoms
- Data always fresh on every request
- No caching behavior observed
- Build logs don't show cached routes
Checklist
1. Is cacheComponents enabled?
// next.config.ts
const nextConfig: NextConfig = {
cacheComponents: true, // Required!
}
2. Is the function async?
// Must be async
async function CachedData() {
'use cache'
return await fetchData()
}
3. Is 'use cache' the first statement?
// ❌ WRONG: Directive not first
async function CachedData() {
const x = 1 // Something before 'use cache'
;('use cache')
return await fetchData()
}
// ✅ CORRECT: Directive first
async function CachedData() {
'use cache'
const x = 1
return await fetchData()
}
4. Are arguments serializable?
// ❌ WRONG: Function as argument (not serializable)
async function CachedData({ transform }: { transform: (x: any) => any }) {
'use cache'
const data = await fetchData()
return transform(data)
}
// ✅ CORRECT: Only serializable arguments
async function CachedData({ transformType }: { transformType: string }) {
'use cache'
const data = await fetchData()
return applyTransform(data, transformType)
}
Issue: Stale Data After Mutation
Symptoms
- Created/updated data doesn't appear immediately
- Need to refresh page to see changes
Cause
Cache not invalidated after mutation.
Solutions
1. Use updateTag() for immediate consistency:
'use server'
import { updateTag } from 'next/cache'
export async function createPost(data: FormData) {
await db.posts.create({ data })
updateTag('posts') // Immediate invalidation
}
2. Ensure tags match:
// Cache uses this tag
async function Posts() {
'use cache'
cacheTag('posts') // Must match invalidation tag
return await db.posts.findMany()
}
// Invalidation must use same tag
export async function createPost(data: FormData) {
await db.posts.create({ data })
updateTag('posts') // Same tag!
}
3. Invalidate all relevant tags:
export async function updatePost(postId: string, data: FormData) {
const post = await db.posts.update({
where: { id: postId },
data,
})
// Invalidate all affected caches
updateTag('posts') // All posts list
updateTag(`post-${postId}`) // Specific post
updateTag(`author-${post.authorId}`) // Author's posts
}
Issue: Different Cache Values for Same Key
Symptoms
- Cache returns different values for what should be the same query
- Inconsistent behavior across requests
Cause
Arguments are part of cache key. Different argument values = different cache entries.
Solution
Normalize arguments:
// ❌ Problem: Object reference differs
async function CachedData({ options }: { options: { limit: number } }) {
'use cache'
return await fetchData(options)
}
// Each call creates new object = new cache key
<CachedData options={{ limit: 10 }} />
<CachedData options={{ limit: 10 }} /> // Different cache entry!
// ✅ Solution: Use primitives or stable references
async function CachedData({ limit }: { limit: number }) {
'use cache'
return await fetchData({ limit })
}
<CachedData limit={10} />
<CachedData limit={10} /> // Same cache entry!
Issue: Cache Too Aggressive (Stale Data)
Symptoms
- Data doesn't update when expected
- Users see outdated content
Solutions
1. Reduce cache lifetime:
async function FrequentlyUpdatedData() {
'use cache'
cacheLife('seconds') // Short cache
// Or custom short duration
cacheLife({
stale: 0,
revalidate: 30,
expire: 60,
})
return await fetchData()
}
2. Don't cache volatile data:
// For truly real-time data, skip caching
async function LiveData() {
// No 'use cache'
return await fetchLiveData()
}
export default function Page() {
return (
<Suspense fallback={<Loading />}>
<LiveData />
</Suspense>
)
}
Issue: Build Takes Too Long
Symptoms
- Build hangs during prerendering
- Timeout errors during
next build
Cause
Cached functions making slow network requests or accessing unavailable services during build.
Solutions
1. Use fallback data for build:
async function CachedData() {
'use cache'
try {
return await fetchFromAPI()
} catch (error) {
// Return fallback during build if API unavailable
return getFallbackData()
}
}
2. Limit static generation scope:
// app/[slug]/page.tsx
export function generateStaticParams() {
// Only prerender most important pages at build time
// Other pages will be generated on-demand at request time
return [{ slug: 'home' }, { slug: 'about' }]
}
3. Use Suspense for truly dynamic content:
// app/[slug]/page.tsx
import { Suspense } from 'react'
export default function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
return (
<Suspense fallback={<PageSkeleton />}>
<DynamicContent params={params} />
</Suspense>
)
}
Note: Avoid using
export const dynamic = 'force-dynamic'as this segment config is deprecated with Cache Components. Use Suspense boundaries and'use cache'for granular control instead.
Debugging Techniques
1. Check Cache Headers
In development, inspect response headers:
curl -I http://localhost:3000/your-page
Look for:
x-nextjs-cache: HIT- Served from cachex-nextjs-cache: MISS- Cache miss, recomputedx-nextjs-cache: STALE- Stale content, revalidating
2. Enable Verbose Logging
# Environment variable for cache debugging
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev
3. Check Build Output
npm run build
# Look for:
# ○ (Static) - Fully static
# ◐ (Partial) - Partial prerender with cache
# λ (Dynamic) - Server-rendered
4. Inspect Cache Tags
Add logging to verify tags:
async function CachedData({ id }: { id: string }) {
'use cache'
const tags = ['data', `item-${id}`]
console.log('Cache tags:', tags) // Check during build
tags.forEach((tag) => cacheTag(tag))
cacheLife('hours')
return await fetchData(id)
}
Common Mistakes Checklist
| Mistake | Symptom | Fix |
|---|---|---|
Missing cacheComponents: true |
No caching | Add to next.config.ts |
Sync function with 'use cache' |
Build error | Make function async |
'use cache' not first statement |
Cache ignored | Move to first line |
| Accessing cookies/headers in cache | Timeout error | Extract to wrapper |
| Non-serializable arguments | Inconsistent cache | Use primitives |
| Missing Suspense for dynamic | Streaming broken | Wrap in Suspense |
| Wrong tag in invalidation | Stale data | Match cache tags |
| Over-caching volatile data | Stale data | Reduce cacheLife |
Performance Optimization Tips
1. Profile Cache Hit Rates
Monitor cache effectiveness:
async function CachedData() {
'use cache'
const start = performance.now()
const data = await fetchData()
const duration = performance.now() - start
// Log for analysis
console.log(`Cache execution: ${duration}ms`)
return data
}
2. Optimize Cache Granularity
// ❌ Coarse: One big cached component
async function PageContent() {
'use cache'
const header = await fetchHeader()
const posts = await fetchPosts()
const sidebar = await fetchSidebar()
return <>{/* everything */}</>
}
// ✅ Fine-grained: Independent cached components
async function Header() {
'use cache'
cacheLife('days')
return await fetchHeader()
}
async function Posts() {
'use cache'
cacheLife('hours')
return await fetchPosts()
}
async function Sidebar() {
'use cache'
cacheLife('minutes')
return await fetchSidebar()
}
3. Strategic Tag Design
// Hierarchical tags for targeted invalidation
cacheTag(
'posts', // All posts
`category-${category}`, // Posts in category
`post-${id}`, // Specific post
`author-${authorId}` // Author's posts
)
// Invalidate at appropriate level
updateTag(`post-${id}`) // Single post changed
updateTag(`author-${author}`) // Author updated all posts
updateTag('posts') // Nuclear option