General

cache-components

Expert guidance for Next.js Cache Components and Partial Prerendering (PPR). **PROACTIVE ACTIVATION**: Use this skill automatically when working in Next.js projects that have `cacheComponents: true` in their next.config.ts/next.config.js. When this config is detected, proactively apply Cache Components patterns and best practices to all React Server Component implementations. **DETECTION**: At the start of a session in a Next.js project, check for `cacheComponents: true` in next.config. If enabled, this skill's patterns should guide all component authoring, data fetching, and caching decisions. **USE CASES**: Implementing 'use cache' directive, configuring cache lifetimes with cacheLife(), tagging cached data with cacheTag(), invalidating caches with updateTag()/revalidateTag(), optimizing static vs dynamic content boundaries, debugging cache issues, and reviewing Cache Component implementations.

data/skills-content.json#vercel-nextjs-cache-components

Next.js Cache Components

Auto-activation: This skill activates automatically in projects with cacheComponents: true in 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:

  1. Must provide at least one parameter - Empty arrays now cause build errors (prevents silent production failures)
  2. Params prove static safety - Providing params lets Next.js verify no dynamic APIs are called
  3. 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

Code Generation Guidelines

When generating Cache Component code:

  1. Always use async - All cached functions must be async
  2. Place 'use cache' first - Must be first statement in function body
  3. Call cacheLife() early - Should follow 'use cache' directive
  4. Tag meaningfully - Use semantic tags that match your invalidation needs
  5. Extract runtime data - Move cookies()/headers() outside cached scope
  6. 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 with cacheLife() 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:

  1. Category subshell (/products/jackets/[slug]) served instantly
  2. Product details stream in as they load
  3. 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

  1. Must be async - All cached functions must return a Promise
  2. First statement - 'use cache' must be the first statement in the function body
  3. No runtime APIs - Cannot call cookies(), headers(), searchParams directly
  4. Serializable arguments - All arguments must be serializable (no functions, class instances)
  5. 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 < 300 seconds (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:

  • expire defines the maximum lifetime of the cache entry
  • If expire is very short, the cached content would immediately become invalid in the static shell
  • stale only 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 time
  • cacheLife('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 /blog layout and all nested routes
  • revalidatePath('/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 accepts stale, revalidate, and expire, the revalidateTag() object form only accepts expire. 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

  1. Must return at least one parameter set - Empty arrays cause build errors
  2. Params validate static safety - Next.js uses provided params to verify no dynamic APIs are accessed
  3. 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, and expire
  • 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-static silently broke dynamic APIs (cookies, headers return empty)
  • force-dynamic prevented 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 UseCacheTimeoutError with 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
  • generateStaticParams provides valid parameters to test rendering

Quick Debugging Checklist

Copy this checklist when debugging cache issues:

Cache Not Working

  • cacheComponents: true in 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() or revalidateTag() after mutation?
  • Tag in invalidation matches tag in cacheTag()?
  • Using updateTag() (not revalidateTag()) for immediate updates?

Build Errors

  • Dynamic data wrapped in <Suspense>?
  • generateStaticParams returns at least one param?
  • Not mixing 'use cache' with cookies()/headers()?

Performance Issues

  • Cache granularity appropriate? (not too coarse/fine)
  • cacheLife set 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 cache
  • x-nextjs-cache: MISS - Cache miss, recomputed
  • x-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
Raw SKILL.md
---
name: cache-components
description: Expert guidance for Next.js Cache Components and Partial Prerendering (PPR). **PROACTIVE ACTIVATION**: Use this skill automatically when working in Next.js projects that have `cacheComponents: true` in their next.config.ts/next.config.js. When this config is detected, proactively apply Cache Components patterns and best practices to all React Server Component implementations. **DETECTION**: At the start of a session in a Next.js project, check for `cacheComponents: true` in next.config. If enabled, this skill's patterns should guide all component authoring, data fetching, and caching decisions. **USE CASES**: Implementing 'use cache' directive, configuring cache lifetimes with cacheLife(), tagging cached data with cacheTag(), invalidating caches with updateTag()/revalidateTag(), optimizing static vs dynamic content boundaries, debugging cache issues, and reviewing Cache Component implementations.
---

# Next.js Cache Components

> **Auto-activation**: This skill activates automatically in projects with `cacheComponents: true` in next.config.

## Project Detection

When starting work in a Next.js project, check if Cache Components are enabled:

```bash
# 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

```typescript
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
```

### Basic Usage

```tsx
// 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:

```tsx
// 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

```tsx
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

```tsx
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:

```tsx
'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:

```tsx
'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.

```tsx
// 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:

1. **Must provide at least one parameter** - Empty arrays now cause build errors (prevents silent production failures)
2. **Params prove static safety** - Providing params lets Next.js verify no dynamic APIs are called
3. **Partial params create subshells** - Each unique permutation generates a reusable shell

```tsx
// ❌ 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:

```tsx
// 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>`:

```tsx
<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:

```tsx
// 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](REFERENCE.md)
- For common patterns and recipes, see [PATTERNS.md](PATTERNS.md)
- For debugging and troubleshooting, see [TROUBLESHOOTING.md](TROUBLESHOOTING.md)

## Code Generation Guidelines

When generating Cache Component code:

1. **Always use `async`** - All cached functions must be async
2. **Place `'use cache'` first** - Must be first statement in function body
3. **Call `cacheLife()` early** - Should follow `'use cache'` directive
4. **Tag meaningfully** - Use semantic tags that match your invalidation needs
5. **Extract runtime data** - Move `cookies()`/`headers()` outside cached scope
6. **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'`:

```tsx
// 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:

```tsx
'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:

```tsx
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 with `cacheLife()` 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:

```tsx
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:

```tsx
// 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:

```tsx
// 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:

```tsx
// 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:

```tsx
// 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:

```tsx
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:

```tsx
// 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:

```tsx
// 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:

```tsx
// 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:

```tsx
// 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 }))
}
```

```tsx
// 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`:

1. Category subshell (`/products/jackets/[slug]`) served instantly
2. Product details stream in as they load
3. 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:

```tsx
// 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

```tsx
// 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)

```tsx
// 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

```tsx
// 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

```tsx
// 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

```tsx
// 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

```tsx
// 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.

```tsx
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

1. **Must be async** - All cached functions must return a Promise
2. **First statement** - `'use cache'` must be the first statement in the function body
3. **No runtime APIs** - Cannot call `cookies()`, `headers()`, `searchParams` directly
4. **Serializable arguments** - All arguments must be serializable (no functions, class instances)
5. **Serializable return values** - Cached functions must return serializable data (no functions, class instances)

---

## Function: `cacheLife()`

Configures cache duration and revalidation behavior.

### Import

```tsx
import { cacheLife } from 'next/cache'
```

### Signature

```tsx
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 < 300` seconds (like `'seconds'`) are treated as **dynamic** and won't be included in the static shell during Partial Prerendering. See [Dynamic Threshold](#dynamic-threshold) below.

### Custom Profiles

Define custom profiles in `next.config.ts`:

```typescript
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

```tsx
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:

- `expire` defines the **maximum lifetime** of the cache entry
- If `expire` is very short, the cached content would immediately become invalid in the static shell
- `stale` only 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 time
- `cacheLife('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.

```tsx
// 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

```tsx
import { cacheTag } from 'next/cache'
```

### Signature

```tsx
function cacheTag(...tags: string[]): void
```

### Usage

```tsx
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**:

```tsx
cacheTag('posts') // All posts
cacheTag(`post-${postId}`) // Specific post
cacheTag(`user-${userId}-posts`) // User's posts
```

**Feature-based tagging**:

```tsx
cacheTag('homepage')
cacheTag('dashboard')
cacheTag('admin')
```

**Combined approach**:

```tsx
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 |

```tsx
// ❌ 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:

```tsx
'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 `/blog` layout and all nested routes
- `revalidatePath('/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                    |

```tsx
// 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**:

```tsx
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:

```tsx
// ✅ 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

```tsx
import { updateTag } from 'next/cache'
```

### Signature

```tsx
function updateTag(tag: string): void
```

### Usage

```tsx
'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

```tsx
import { revalidateTag } from 'next/cache'
```

### Signature

```tsx
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 accepts `stale`, `revalidate`, and `expire`, the `revalidateTag()` object form only accepts `expire`. Use a predefined profile name (like `'hours'`) for full control over stale-while-revalidate behavior.

### Usage

```tsx
'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

```tsx
'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

```tsx
import { revalidatePath } from 'next/cache'
```

### Signature

```tsx
function revalidatePath(path: string, type?: 'page' | 'layout'): void
```

### Usage

```tsx
'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

```typescript
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig
```

### Configure Cache Handlers

```typescript
const nextConfig: NextConfig = {
  cacheHandlers: {
    default: {
      maxMemorySize: 52428800, // 50MB
    },
    // Platform-specific remote handler
    remote: CustomRemoteHandler,
  },
}
```

### Define Cache Profiles

```typescript
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:

```tsx
// 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

1. **Must return at least one parameter set** - Empty arrays cause build errors
2. **Params validate static safety** - Next.js uses provided params to verify no dynamic APIs are accessed
3. **Subshells require Suspense** - If accessing unknown params without Suspense, no subshell is generated

```tsx
// ❌ 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:

```tsx
// 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:**

```tsx
// 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):**

```tsx
// 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`, and `expire`
- Different functions can have different lifetimes
- Can conditionally set cache life based on data

### `export const dynamic` (Deprecated)

**Before:**

```tsx
// 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-static` silently broke dynamic APIs (cookies, headers return empty)
- `force-dynamic` prevented any static optimization
- Hidden bugs when dynamic components received empty data

**After (Cache Components):**

```tsx
// 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:**

```tsx
// app/products/page.tsx
export const revalidate = 3600

export default async function ProductsPage() {
  const products = await db.products.findMany()
  return <ProductGrid products={products} />
}
```

**After:**

```tsx
// 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:**

```tsx
// 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:**

```tsx
// 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:**

```tsx
// 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:**

```tsx
// 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](https://nextjs.org/docs/app/building-your-application/configuring/draft-mode) is enabled, cache entries are **not saved**:

```tsx
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 `UseCacheTimeoutError` with code `'USE_CACHE_TIMEOUT'`

```tsx
// 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**:

```tsx
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

```typescript
type CacheLife = {
  stale?: number // Default: 300 (from staleTimes.static)
  revalidate?: number // Default: profile-dependent
  expire?: number // Default: profile-dependent
}
```

### CacheLifeProfile

```typescript
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
- `generateStaticParams` provides valid parameters to test rendering

---

## Quick Debugging Checklist

Copy this checklist when debugging cache issues:

### Cache Not Working

- [ ] `cacheComponents: true` in 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()` or `revalidateTag()` after mutation?
- [ ] Tag in invalidation matches tag in `cacheTag()`?
- [ ] Using `updateTag()` (not `revalidateTag()`) for immediate updates?

### Build Errors

- [ ] Dynamic data wrapped in `<Suspense>`?
- [ ] `generateStaticParams` returns at least one param?
- [ ] Not mixing `'use cache'` with `cookies()`/`headers()`?

### Performance Issues

- [ ] Cache granularity appropriate? (not too coarse/fine)
- [ ] `cacheLife` set 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:

```tsx
// ❌ 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

```tsx
// ❌ 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:

```tsx
// ❌ 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:

```tsx
// ❌ 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

```tsx
// ❌ 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:

```tsx
// ❌ 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?**

```typescript
// next.config.ts
const nextConfig: NextConfig = {
  cacheComponents: true, // Required!
}
```

**2. Is the function async?**

```tsx
// Must be async
async function CachedData() {
  'use cache'
  return await fetchData()
}
```

**3. Is `'use cache'` the first statement?**

```tsx
// ❌ 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?**

```tsx
// ❌ 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:**

```tsx
'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:**

```tsx
// 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:**

```tsx
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:

```tsx
// ❌ 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:**

```tsx
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:**

```tsx
// 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:**

```tsx
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:**

```tsx
// 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:**

```tsx
// 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:

```bash
curl -I http://localhost:3000/your-page
```

Look for:

- `x-nextjs-cache: HIT` - Served from cache
- `x-nextjs-cache: MISS` - Cache miss, recomputed
- `x-nextjs-cache: STALE` - Stale content, revalidating

### 2. Enable Verbose Logging

```bash
# Environment variable for cache debugging
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev
```

### 3. Check Build Output

```bash
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:

```tsx
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:

```tsx
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

```tsx
// ❌ 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

```tsx
// 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
```
Source: vercel-nextjs | License: MIT