Back to Blog

Next.js Caching Deep Dive: Cache Components and the use cache Directive

Introduction

Next.js 16 introduces Cache Components - a declarative, explicit caching model that fundamentally changes how you think about caching in React applications. Unlike the implicit caching behavior of previous versions, Cache Components requires explicit opt-in via the use cache directive, making cache behavior predictable and debuggable.

This guide explores the internals of this new caching architecture, serialization constraints, cache key generation, Partial Prerendering (PPR), and production patterns for building high-performance applications.

Architecture Overview

┌─────────────────────────────────────────────────────────────────────────────┐
│                        NEXT.JS CACHE COMPONENTS ARCHITECTURE                │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                         BUILD TIME (PRERENDER)                       │   │
│  │  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────────┐  │   │
│  │  │ Static      │    │ use cache   │    │ Suspense Boundaries     │  │   │
│  │  │ Content     │───▶│ Functions   │───▶│ (Fallback UI)           │  │   │
│  │  │ (Pure)      │    │ (Cached)    │    │                         │  │   │
│  │  └─────────────┘    └─────────────┘    └─────────────────────────┘  │   │
│  │         │                  │                       │                │   │
│  │         ▼                  ▼                       ▼                │   │
│  │  ┌───────────────────────────────────────────────────────────────┐  │   │
│  │  │                    STATIC SHELL (HTML + RSC Payload)          │  │   │
│  │  │  • Deterministic operations rendered immediately              │  │   │
│  │  │  • use cache output included in shell                        │  │   │
│  │  │  • Suspense fallbacks for dynamic content                    │  │   │
│  │  └───────────────────────────────────────────────────────────────┘  │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                           REQUEST TIME                               │   │
│  │                                                                      │   │
│  │  ┌──────────────────────────────────────────────────────────────┐   │   │
│  │  │                    SERVER CACHE (LRU)                         │   │   │
│  │  │  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐       │   │   │
│  │  │  │ Cache Entry  │  │ Cache Entry  │  │ Cache Entry  │       │   │   │
│  │  │  │ key: hash    │  │ key: hash    │  │ key: hash    │       │   │   │
│  │  │  │ stale: 5m    │  │ revalidate   │  │ expire: 1d   │       │   │   │
│  │  │  │ revalidate   │  │ in progress  │  │              │       │   │   │
│  │  │  └──────────────┘  └──────────────┘  └──────────────┘       │   │   │
│  │  └──────────────────────────────────────────────────────────────┘   │   │
│  │                              │                                       │   │
│  │                              ▼                                       │   │
│  │  ┌──────────────────────────────────────────────────────────────┐   │   │
│  │  │                    CLIENT CACHE (Router)                      │   │   │
│  │  │  • x-nextjs-stale-time header communicates lifetime           │   │   │
│  │  │  • Minimum 30-second stale time enforced                      │   │   │
│  │  │  • Instant page loads from client cache                       │   │   │
│  │  │  • Cleared on revalidation (updateTag, refresh)               │   │   │
│  │  └──────────────────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                     STREAMING (DYNAMIC HOLES)                        │   │
│  │                                                                      │   │
│  │  Browser receives static shell immediately                          │   │
│  │                     │                                                │   │
│  │                     ▼                                                │   │
│  │  ┌─────────────────────────────────────────────────────────────┐    │   │
│  │  │  Loading...  │  Loading...  │  ← Fallback UI visible        │    │   │
│  │  └─────────────────────────────────────────────────────────────┘    │   │
│  │                     │                                                │   │
│  │          Server streams resolved content                            │   │
│  │                     ▼                                                │   │
│  │  ┌─────────────────────────────────────────────────────────────┐    │   │
│  │  │  User Data   │  Live Stats  │  ← Dynamic content injected   │    │   │
│  │  └─────────────────────────────────────────────────────────────┘    │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘

Enabling Cache Components

Cache Components is enabled via the cacheComponents flag in next.config.ts:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

This enables:

  • The use cache directive
  • Partial Prerendering (PPR) by default
  • New caching semantics for Route Handlers
  • cacheLife and cacheTag functions

The use cache Directive

Core Concept

use cache marks async functions and components as cacheable. Unlike the old implicit caching, this is explicit opt-in:

// Data-level caching
export async function getProducts() {
  'use cache'
  return db.query('SELECT * FROM products')
}

// UI-level caching
export default async function ProductsPage() {
  'use cache'
  const products = await getProducts()
  return <ProductList products={products} />
}

// File-level caching (all exports cached)
'use cache'

export async function getUsers() {
  return db.query('SELECT * FROM users')
}

export async function getPosts() {
  return db.query('SELECT * FROM posts')
}

Cache Key Generation

Cache keys are automatically generated from:

┌─────────────────────────────────────────────────────────────────┐
│                    CACHE KEY COMPOSITION                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  ┌─────────────────┐                                           │
│  │    Build ID     │  Unique per build - invalidates all       │
│  │    (hash)       │  cache entries on new deployment          │
│  └────────┬────────┘                                           │
│           │                                                     │
│           ▼                                                     │
│  ┌─────────────────┐                                           │
│  │   Function ID   │  Hash of function location + signature    │
│  │    (hash)       │  in the codebase                          │
│  └────────┬────────┘                                           │
│           │                                                     │
│           ▼                                                     │
│  ┌─────────────────┐                                           │
│  │   Arguments     │  Serialized props (components) or         │
│  │  (serialized)   │  function arguments                       │
│  └────────┬────────┘                                           │
│           │                                                     │
│           ▼                                                     │
│  ┌─────────────────┐                                           │
│  │ Closure Values  │  Captured variables from outer scopes     │
│  │  (serialized)   │  automatically become part of key         │
│  └────────┬────────┘                                           │
│           │                                                     │
│           ▼                                                     │
│  ┌─────────────────┐                                           │
│  │   HMR Hash      │  Development only - invalidates on        │
│  │  (dev only)     │  hot module replacement                   │
│  └─────────────────┘                                           │
│                                                                 │
│  Final Key = hash(buildId + funcId + args + closures + [hmr])  │
└─────────────────────────────────────────────────────────────────┘

Closure capture example:

async function UserComponent({ userId }: { userId: string }) {
  // userId captured from closure
  const getData = async (filter: string) => {
    'use cache'
    // Cache key includes BOTH userId (closure) and filter (argument)
    return fetch(`/api/users/${userId}/data?filter=${filter}`)
  }

  return getData('active')
}

Serialization Requirements

Arguments and return values must be serializable. The serialization systems differ:

┌─────────────────────────────────────────────────────────────────────────┐
│                    SERIALIZATION SYSTEMS                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ARGUMENTS (React Server Components serialization - MORE RESTRICTIVE)  │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │  ✓ Primitives: string, number, boolean, null, undefined         │   │
│  │  ✓ Plain objects: { key: value }                                │   │
│  │  ✓ Arrays: [1, 2, 3]                                            │   │
│  │  ✓ Dates, Maps, Sets, TypedArrays, ArrayBuffers                 │   │
│  │  ✓ React elements (pass-through only, not introspected)        │   │
│  │                                                                  │   │
│  │  ✗ Class instances                                               │   │
│  │  ✗ Functions (except as pass-through)                            │   │
│  │  ✗ Symbols, WeakMaps, WeakSets                                   │   │
│  │  ✗ URL instances                                                 │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  RETURN VALUES (React Client Components serialization - LESS STRICT)   │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │  ✓ Everything above PLUS:                                       │   │
│  │  ✓ JSX elements (React.ReactNode)                               │   │
│  │  ✓ Promises (will be awaited)                                   │   │
│  └─────────────────────────────────────────────────────────────────┘   │
│                                                                         │
│  KEY INSIGHT: You CAN return JSX but CANNOT accept it as arguments     │
│               unless using pass-through patterns                       │
└─────────────────────────────────────────────────────────────────────────┘

Pass-Through Pattern (Non-Serializable Arguments)

Accept non-serializable values as long as you don't introspect them:

// ✓ Valid - children passed through without inspection
async function CachedWrapper({ children }: { children: ReactNode }) {
  'use cache'
  const cachedData = await fetchExpensiveData()

  return (
    <div className="wrapper">
      <ExpensiveHeader data={cachedData} />
      {children}  {/* Passed through - NOT cached */}
    </div>
  )
}

// Usage: dynamic children work correctly
export default function Page() {
  return (
    <CachedWrapper>
      <DynamicUserContent />  {/* Not cached, fresh on every request */}
    </CachedWrapper>
  )
}

Server Actions pass-through:

async function CachedForm({ action }: { action: () => Promise<void> }) {
  'use cache'
  // Don't call action here - just pass through
  return <form action={action}>{/* ... */}</form>
}

// Usage
export default function Page() {
  async function submitForm() {
    'use server'
    await db.insert(/* ... */)
  }

  return <CachedForm action={submitForm} />
}

Cache Lifetime with cacheLife

Profile Semantics

┌────────────────────────────────────────────────────────────────────────────┐
│                       CACHE LIFETIME PROPERTIES                            │
├────────────────────────────────────────────────────────────────────────────┤
│                                                                            │
│  ┌────────────────────────────────────────────────────────────────────┐   │
│  │  STALE (Client-side)                                                │   │
│  │  • How long client can use cached data WITHOUT checking server     │   │
│  │  • Enables instant page loads from client cache                    │   │
│  │  • Data may be outdated during this period                         │   │
│  │  • Minimum 30 seconds enforced (prefetch protection)               │   │
│  └────────────────────────────────────────────────────────────────────┘   │
│                                                                            │
│  ┌────────────────────────────────────────────────────────────────────┐   │
│  │  REVALIDATE (Server-side)                                           │   │
│  │  • After this period, next request triggers background refresh      │   │
│  │  • Stale content served immediately while refresh happens           │   │
│  │  • Similar to ISR (Incremental Static Regeneration)                 │   │
│  │  • Cache updated with fresh content for subsequent requests         │   │
│  └────────────────────────────────────────────────────────────────────┘   │
│                                                                            │
│  ┌────────────────────────────────────────────────────────────────────┐   │
│  │  EXPIRE (Maximum lifetime)                                          │   │
│  │  • Maximum time before server MUST regenerate                       │   │
│  │  • After this period with no traffic, next request blocks           │   │
│  │  • Must be longer than revalidate                                   │   │
│  │  • Protects against unbounded cache growth                          │   │
│  └────────────────────────────────────────────────────────────────────┘   │
│                                                                            │
│  TIMELINE EXAMPLE (hours profile):                                         │
│                                                                            │
│  Request ────────────────────────────────────────────────────▶ Time        │
│     │                                                                      │
│     ├─── stale (5m) ───┤                                                   │
│     │    Client uses   │                                                   │
│     │    cache directly│                                                   │
│     │                  │                                                   │
│     ├──────────────────┼── revalidate (1h) ───────────────┤               │
│     │                  │   Background refresh on next req  │               │
│     │                  │                                   │               │
│     ├──────────────────┴───────────────────────────────────┼── expire ──▶ │
│     │                                                       │   (1d)      │
│     │                   Cache valid, refreshing in bg       │   Forced    │
│                                                                 regen     │
└────────────────────────────────────────────────────────────────────────────┘

Preset Profiles

ProfileUse Casestalerevalidateexpire
defaultStandard content5 min15 minnever
secondsReal-time data (stocks, live scores)30 sec1 sec1 min
minutesFrequently updated (social feeds)5 min1 min1 hour
hoursMultiple daily updates (inventory)5 min1 hour1 day
daysDaily updates (blog posts)5 min1 day1 week
weeksWeekly updates (podcasts)5 min1 week30 days
maxStable content (legal pages)5 min30 days1 year
import { cacheLife } from 'next/cache'

async function getStockPrice(symbol: string) {
  'use cache'
  cacheLife('seconds')  // Real-time financial data
  return fetchStockAPI(symbol)
}

async function getBlogPost(slug: string) {
  'use cache'
  cacheLife('days')  // Content updated daily
  return fetchBlogPost(slug)
}

async function getTermsOfService() {
  'use cache'
  cacheLife('max')  // Rarely changes
  return fetchLegalContent('tos')
}

Custom Profiles

Define reusable profiles in next.config.ts:

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

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    // Editorial content: checked hourly, expires daily
    editorial: {
      stale: 60 * 10,        // 10 minutes
      revalidate: 60 * 60,    // 1 hour
      expire: 60 * 60 * 24,   // 1 day
    },
    // Marketing pages: fresher content
    marketing: {
      stale: 60 * 5,          // 5 minutes
      revalidate: 60 * 30,    // 30 minutes
      expire: 60 * 60 * 12,   // 12 hours
    },
    // User-generated content: faster refresh
    ugc: {
      stale: 60,              // 1 minute
      revalidate: 60 * 5,     // 5 minutes
      expire: 60 * 60,        // 1 hour
    },
  },
}

export default nextConfig

Usage:

async function getEditorialContent(id: string) {
  'use cache'
  cacheLife('editorial')
  return fetchEditorial(id)
}

Inline Profiles

For one-off cases:

async function getLimitedOffer() {
  'use cache'
  cacheLife({
    stale: 60,        // 1 minute
    revalidate: 300,  // 5 minutes
    expire: 3600,     // 1 hour
  })
  return fetchLimitedOffer()
}

Conditional Cache Lifetimes

Different outcomes can have different durations:

import { cacheLife, cacheTag } from 'next/cache'

async function getContent(slug: string) {
  'use cache'

  const content = await fetchContent(slug)
  cacheTag(`content-${slug}`)

  if (!content) {
    // Not found - might be published soon, cache briefly
    cacheLife('minutes')
    return null
  }

  if (content.status === 'draft') {
    // Draft content - check frequently
    cacheLife('minutes')
    return content
  }

  // Published content - cache longer
  cacheLife('days')
  return content
}

Dynamic Lifetimes from Data

async function getPost(slug: string) {
  'use cache'

  const post = await fetchPost(slug)
  cacheTag(`post-${slug}`)

  // Use CMS-provided cache settings
  cacheLife({
    revalidate: post.cacheRevalidateSeconds ?? 3600,
    // stale and expire inherit from 'default'
  })

  return post
}

On-Demand Invalidation with cacheTag

Tagging Cache Entries

import { cacheLife, cacheTag } from 'next/cache'

async function getProducts(category: string) {
  'use cache'
  cacheTag('products', `category-${category}`)
  cacheLife('hours')

  return db.products.findMany({
    where: { category },
  })
}

async function getProduct(id: string) {
  'use cache'
  cacheTag('products', `product-${id}`)
  cacheLife('hours')

  return db.products.findUnique({ where: { id } })
}

Invalidation Functions

Three functions for invalidation with different behaviors:

'use server'

import { updateTag, revalidateTag, revalidatePath, refresh } from 'next/cache'

// updateTag: Immediately expire cache entries with tag
// Best for: Most mutations where you want fresh data
export async function updateProduct(id: string, data: ProductData) {
  await db.products.update({ where: { id }, data })
  updateTag(`product-${id}`)  // Immediate invalidation
  updateTag('products')        // Also invalidate product lists
}

// revalidateTag: Schedule background revalidation
// Best for: When stale data is acceptable briefly
export async function scheduleProductRefresh(id: string) {
  revalidateTag(`product-${id}`)  // Will revalidate on next request
}

// revalidatePath: Invalidate entire route
// Best for: When multiple caches on a route need refresh
export async function refreshProductPage(slug: string) {
  revalidatePath(`/products/${slug}`)
}

// refresh: Clear entire client cache
// Best for: Major state changes (logout, role change)
export async function handleLogout() {
  await clearSession()
  refresh()  // Clear all client-side cached data
}

Complete Mutation Pattern

// app/products/actions.ts
'use server'

import { updateTag } from 'next/cache'
import { z } from 'zod'

const UpdateProductSchema = z.object({
  name: z.string().min(1),
  price: z.number().positive(),
  category: z.string(),
})

type ActionState = {
  success: boolean
  message?: string
  errors?: Record<string, string[]>
}

export async function updateProduct(
  productId: string,
  prevState: ActionState,
  formData: FormData
): Promise<ActionState> {
  const session = await auth()
  if (!session?.user) {
    return { success: false, message: 'Unauthorized' }
  }

  const validated = UpdateProductSchema.safeParse({
    name: formData.get('name'),
    price: Number(formData.get('price')),
    category: formData.get('category'),
  })

  if (!validated.success) {
    return {
      success: false,
      errors: validated.error.flatten().fieldErrors,
    }
  }

  const product = await db.products.findUnique({
    where: { id: productId },
  })

  const oldCategory = product?.category
  const newCategory = validated.data.category

  await db.products.update({
    where: { id: productId },
    data: validated.data,
  })

  // Invalidate affected caches
  updateTag(`product-${productId}`)
  updateTag('products')

  // If category changed, invalidate both category listings
  if (oldCategory !== newCategory) {
    updateTag(`category-${oldCategory}`)
    updateTag(`category-${newCategory}`)
  }

  return { success: true, message: 'Product updated' }
}

Partial Prerendering (PPR)

How PPR Works

With Cache Components enabled, Next.js automatically uses Partial Prerendering:

┌─────────────────────────────────────────────────────────────────────────────┐
│                     PARTIAL PRERENDERING FLOW                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  BUILD TIME                                                                 │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  Component Tree Analysis                                             │   │
│  │                                                                      │   │
│  │  Page                                                                │   │
│  │   ├── Header (static) ───────────────────────▶ [PRERENDER]          │   │
│  │   ├── ProductInfo (use cache) ───────────────▶ [PRERENDER]          │   │
│  │   ├── <Suspense fallback={<Skeleton/>}>                             │   │
│  │   │    └── UserCart (cookies()) ─────────────▶ [STREAM AT REQUEST]  │   │
│  │   └── Footer (static) ───────────────────────▶ [PRERENDER]          │   │
│  │                                                                      │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                    │                                        │
│                                    ▼                                        │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  Static Shell Generated                                              │   │
│  │                                                                      │   │
│  │  ┌─────────────────────────────────────────────────────────────┐    │   │
│  │  │ <html>                                                       │    │   │
│  │  │   <body>                                                     │    │   │
│  │  │     <Header>Logo | Nav</Header>              ← Prerendered  │    │   │
│  │  │     <ProductInfo>iPhone 15 - $999</ProductInfo> ← Cached    │    │   │
│  │  │     <div id="cart-placeholder">                              │    │   │
│  │  │       <Skeleton />                           ← Fallback     │    │   │
│  │  │     </div>                                                   │    │   │
│  │  │     <Footer>© 2024</Footer>                  ← Prerendered  │    │   │
│  │  │   </body>                                                    │    │   │
│  │  │ </html>                                                      │    │   │
│  │  └─────────────────────────────────────────────────────────────┘    │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  REQUEST TIME                                                               │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │                                                                      │   │
│  │  1. Browser receives static shell IMMEDIATELY                       │   │
│  │     └── User sees header, product info, skeleton, footer            │   │
│  │                                                                      │   │
│  │  2. Server streams dynamic content                                   │   │
│  │     └── UserCart reads cookies, fetches cart data                   │   │
│  │                                                                      │   │
│  │  3. Browser replaces skeleton with cart                              │   │
│  │     └── Page fully interactive                                      │   │
│  │                                                                      │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────────┘

Implementation Example

// app/products/[id]/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <>
      {/* Static - prerendered automatically */}
      <Header />

      {/* Cached - included in static shell */}
      <ProductDetails productId={params.id} />

      {/* Dynamic - streamed at request time */}
      <Suspense fallback={<CartSkeleton />}>
        <UserCart />
      </Suspense>

      {/* Cached - included in static shell */}
      <RelatedProducts productId={params.id} />

      {/* Static - prerendered automatically */}
      <Footer />
    </>
  )
}

// Cached component - part of static shell
async function ProductDetails({ productId }: { productId: string }) {
  'use cache'
  cacheTag(`product-${productId}`)
  cacheLife('hours')

  const product = await db.products.findUnique({
    where: { id: productId },
    include: { images: true, specs: true },
  })

  return (
    <div>
      <h1>{product.name}</h1>
      <ImageGallery images={product.images} />
      <Price amount={product.price} />
      <Specs data={product.specs} />
    </div>
  )
}

// Dynamic component - streams at request time
async function UserCart() {
  const cartId = (await cookies()).get('cartId')?.value

  if (!cartId) {
    return <EmptyCartPrompt />
  }

  const cart = await db.carts.findUnique({
    where: { id: cartId },
    include: { items: { include: { product: true } } },
  })

  return <CartDisplay cart={cart} />
}

// Cached component - part of static shell
async function RelatedProducts({ productId }: { productId: string }) {
  'use cache'
  cacheTag('products')
  cacheLife('hours')

  const related = await getRelatedProducts(productId)
  return <ProductGrid products={related} />
}

Opting Out of Static Shell

For fully dynamic pages:

// app/layout.tsx
import { Suspense } from 'react'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <Suspense fallback={null}>  {/* Empty fallback = no static shell */}
        <body>{children}</body>
      </Suspense>
    </html>
  )
}

Working with Runtime APIs

Runtime APIs (cookies, headers, searchParams) are only available at request time and cannot be used inside use cache boundaries.

Pattern: Extract and Pass

import { cookies } from 'next/headers'
import { Suspense } from 'react'

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <ProfileContent />
    </Suspense>
  )
}

// Non-cached wrapper reads runtime data
async function ProfileContent() {
  const session = (await cookies()).get('session')?.value
  const locale = (await cookies()).get('locale')?.value ?? 'en'

  // Pass extracted values to cached component
  return <CachedProfile sessionId={session} locale={locale} />
}

// Cached component receives values as props (part of cache key)
async function CachedProfile({
  sessionId,
  locale
}: {
  sessionId: string | undefined
  locale: string
}) {
  'use cache'
  cacheLife('minutes')

  if (!sessionId) {
    return <GuestProfile locale={locale} />
  }

  const user = await fetchUserData(sessionId)
  return <UserProfile user={user} locale={locale} />
}

Non-Deterministic Operations

Operations like Math.random(), Date.now(), crypto.randomUUID() require explicit handling:

import { connection } from 'next/server'
import { Suspense } from 'react'

// Option 1: Fresh value per request
async function RequestId() {
  await connection()  // Defer to request time
  const uuid = crypto.randomUUID()
  return <span>Request: {uuid}</span>
}

export function Page() {
  return (
    <Suspense fallback={<span>Loading...</span>}>
      <RequestId />
    </Suspense>
  )
}

// Option 2: Cached value (same for all users until revalidation)
async function BuildId() {
  'use cache'
  cacheLife('max')
  const buildId = crypto.randomUUID()
  return <span>Build: {buildId}</span>
}

Nested Caching Behavior

With Explicit Outer cacheLife

Outer cache uses its own lifetime regardless of inner caches:

async function Dashboard() {
  'use cache'
  cacheLife('hours')  // Explicit - this wins

  return (
    <div>
      <Widget />  {/* Has 'minutes' cacheLife */}
    </div>
  )
}
// Dashboard caches for 1 hour, Widget output included

Without Explicit Outer cacheLife

Inner caches can reduce (but not extend) the default lifetime:

async function Dashboard() {
  'use cache'
  // No cacheLife - uses default (15 min)

  return (
    <div>
      <FastWidget />   {/* 'minutes' (1 min) → Dashboard becomes 1 min */}
      <SlowWidget />   {/* 'days' (1 day) → Dashboard stays 15 min */}
    </div>
  )
}

Short-Lived Cache Warning

Short-lived caches (seconds or <5 min expire) nested in caches without explicit cacheLife cause build errors:

// This will ERROR during build
async function Page() {
  'use cache'
  // No explicit cacheLife

  return <RealtimeWidget />  // Has cacheLife('seconds')
}

// Fix: Add explicit cacheLife
async function Page() {
  'use cache'
  cacheLife('default')  // Explicit - no error

  return <RealtimeWidget />
}

React.cache Isolation

React.cache operates in isolated scope inside use cache boundaries:

import { cache } from 'react'

const store = cache(() => ({ current: null as string | null }))

function Parent() {
  const shared = store()
  shared.current = 'value from parent'
  return <Child />
}

async function Child() {
  'use cache'
  const shared = store()
  // shared.current is NULL, not 'value from parent'
  // use cache has its own isolated React.cache scope
  return <div>{shared.current}</div>
}

// Solution: Pass data as arguments instead
async function Child({ value }: { value: string }) {
  'use cache'
  return <div>{value}</div>
}

Runtime Cache Behavior

Server-Side Caching

┌─────────────────────────────────────────────────────────────────────────────┐
│                    RUNTIME CACHE ENVIRONMENTS                               │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  SERVERLESS (Default Vercel, AWS Lambda)                                   │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  • Each request may hit different instance                          │   │
│  │  • In-memory cache doesn't persist across requests                  │   │
│  │  • Build-time caching works normally                                │   │
│  │  • Consider 'use cache: remote' for runtime persistence             │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  SELF-HOSTED (Docker, Node.js server)                                      │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  • Single long-running process                                      │   │
│  │  • Cache persists across requests                                   │   │
│  │  • LRU eviction when memory fills                                   │   │
│  │  • Configure max size with cacheMaxMemorySize                       │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
│  CACHE VARIANTS                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐   │
│  │  'use cache'          → In-memory LRU (default)                     │   │
│  │  'use cache: remote'  → Platform-provided handler (Redis, KV)       │   │
│  │  'use cache: private' → Per-user caching (compliance scenarios)     │   │
│  └─────────────────────────────────────────────────────────────────────┘   │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘

Client-Side Caching

The Router Cache on the client:

  • Receives stale time via x-nextjs-stale-time header
  • Enforces minimum 30-second stale time (prefetch protection)
  • Cleared entirely on updateTag, revalidateTag, revalidatePath, or refresh
// Client cache behavior
// User navigates to /products (prefetched)
// → Instant load from client cache if within stale window
// → After stale window: check server, may get cached response
// → Server Action calls updateTag('products')
// → Entire client cache cleared
// → Next navigation fetches fresh data

Complete Example: E-Commerce Page

// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag, updateTag } from 'next/cache'
import { notFound } from 'next/navigation'

interface PageProps {
  params: Promise<{ slug: string }>
}

export default async function ProductPage({ params }: PageProps) {
  const { slug } = await params

  return (
    <>
      {/* Static header - prerendered */}
      <Header />

      {/* Cached product - in static shell */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductContent slug={slug} />
      </Suspense>

      {/* Dynamic user content - streams at request */}
      <Suspense fallback={<CartSkeleton />}>
        <UserSection />
      </Suspense>

      {/* Cached recommendations - in static shell */}
      <Recommendations slug={slug} />

      <Footer />
    </>
  )
}

// Cached product data and UI
async function ProductContent({ slug }: { slug: string }) {
  'use cache'
  cacheTag('products', `product-${slug}`)
  cacheLife('hours')

  const product = await db.products.findUnique({
    where: { slug },
    include: {
      images: true,
      variants: true,
      reviews: { take: 10, orderBy: { createdAt: 'desc' } },
    },
  })

  if (!product) {
    notFound()
  }

  return (
    <article>
      <ImageGallery images={product.images} />
      <ProductDetails product={product} />
      <VariantSelector variants={product.variants} />
      <ReviewList reviews={product.reviews} />

      {/* Add to cart form - action passed through */}
      <AddToCartForm productId={product.id} />
    </article>
  )
}

// Dynamic user-specific content
async function UserSection() {
  const cartId = (await cookies()).get('cartId')?.value
  const userId = (await cookies()).get('userId')?.value

  return (
    <aside>
      {cartId && <MiniCart cartId={cartId} />}
      {userId && <RecentlyViewed userId={userId} />}
    </aside>
  )
}

// Non-cached because it needs fresh cart data
async function MiniCart({ cartId }: { cartId: string }) {
  const cart = await db.carts.findUnique({
    where: { id: cartId },
    include: { items: { include: { product: true } } },
  })

  return <CartWidget cart={cart} />
}

// Cached recommendations
async function Recommendations({ slug }: { slug: string }) {
  'use cache'
  cacheTag('products', 'recommendations')
  cacheLife('hours')

  const recommendations = await getRecommendations(slug)
  return <ProductGrid products={recommendations} title="You might also like" />
}

// Server Action for add to cart
async function addToCart(productId: string, variantId: string) {
  'use server'

  const cartId = (await cookies()).get('cartId')?.value

  if (!cartId) {
    // Create new cart
    const cart = await db.carts.create({
      data: {
        items: {
          create: { productId, variantId, quantity: 1 },
        },
      },
    })
    ;(await cookies()).set('cartId', cart.id)
  } else {
    // Add to existing cart
    await db.cartItems.upsert({
      where: { cartId_productId_variantId: { cartId, productId, variantId } },
      create: { cartId, productId, variantId, quantity: 1 },
      update: { quantity: { increment: 1 } },
    })
  }

  // No cache invalidation needed - cart is not cached
  // Client will see updated cart on next navigation
}

Debugging Cache Behavior

Verbose Logging

# Development
NEXT_PRIVATE_DEBUG_CACHE=1 npm run dev

# Production
NEXT_PRIVATE_DEBUG_CACHE=1 npm run start

Console Log Replay

In development, cached function logs appear with Cache prefix:

Cache: getProducts() returned 42 items
Cache: ProductDetails rendered for slug="iphone-15"

Build Output Analysis

npm run build

# Output shows route rendering behavior:
Route (app)                    Size     First Load JS
┌ ○ /                          5.2 kB   89.1 kB
├ ● /products/[slug]           2.1 kB   86.0 kB
│   ├ /products/iphone-15
│   └ /products/macbook-pro
├ λ /api/checkout              0 B      84.0 kB

○ = Static (prerendered)
● = Partial Prerender (static shell + dynamic holes)
λ = Dynamic (server-rendered per request)

Inspecting Static Shell

View page source in browser to see what's prerendered vs. placeholder:

<!-- Prerendered content visible -->
<header>...</header>
<article id="product">...</article>

<!-- Suspense boundary with fallback -->
<div id="cart-suspense">
  <template data-suspense-id="1">...</template>
  <div class="skeleton">Loading cart...</div>
</div>

<!-- Streaming script injected later -->
<script>
  // Replaces skeleton with actual content
</script>

Common Pitfalls and Solutions

Pitfall: Promise from Uncached Context

// ❌ Build hangs - Promise from uncached context
async function Page() {
  const cookieStore = cookies()
  return <Cached promise={cookieStore} />
}

async function Cached({ promise }: { promise: Promise<unknown> }) {
  'use cache'
  const data = await promise  // Waiting for runtime data at build time
  return <div>{data}</div>
}

// ✅ Extract value first, pass primitive
async function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Dynamic />
    </Suspense>
  )
}

async function Dynamic() {
  const session = (await cookies()).get('session')?.value
  return <Cached sessionId={session} />
}

async function Cached({ sessionId }: { sessionId: string | undefined }) {
  'use cache'
  if (!sessionId) return null
  const data = await fetchData(sessionId)
  return <div>{data}</div>
}

Pitfall: Missing Suspense for Runtime APIs

// ❌ Error: Uncached data accessed outside <Suspense>
export default async function Page() {
  const user = await getCurrentUser()  // Uses cookies()
  return <Profile user={user} />
}

// ✅ Wrap in Suspense
export default function Page() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <ProfileLoader />
    </Suspense>
  )
}

async function ProfileLoader() {
  const user = await getCurrentUser()
  return <Profile user={user} />
}

Pitfall: Class Instances as Arguments

// ❌ Cannot serialize class instance
async function UserProfile({ user }: { user: UserClass }) {
  'use cache'
  return <div>{user.name}</div>
}

// ✅ Pass plain objects or primitives
async function UserProfile({ userId }: { userId: string }) {
  'use cache'
  const user = await getUser(userId)  // Fetch inside cache boundary
  return <div>{user.name}</div>
}

// ✅ Or convert to plain object before passing
async function UserProfile({ user }: { user: SerializedUser }) {
  'use cache'
  return <div>{user.name}</div>
}

// In parent:
const user = await getUser(id)
<UserProfile user={{ id: user.id, name: user.name, email: user.email }} />

Key Takeaways

  1. Explicit Over Implicit: Cache Components requires explicit use cache directives - no automatic caching
  2. Cache Keys Are Automatic: Arguments and closures automatically become part of the cache key
  3. Serialization Matters: Arguments must be serializable; use pass-through for complex children
  4. PPR Is Default: With Cache Components, Partial Prerendering happens automatically
  5. Three Time Properties: stale (client), revalidate (background refresh), expire (max lifetime)
  6. Runtime APIs Need Suspense: cookies(), headers(), searchParams require Suspense boundaries
  7. Tags Enable Fine-Grained Invalidation: Use cacheTag and updateTag for surgical cache updates
  8. Nested Caching: Explicit cacheLife on outer cache prevents lifetime inheritance issues
  9. Environment Affects Runtime Caching: Serverless doesn't persist memory cache; consider use cache: remote
  10. Debug with Logging: NEXT_PRIVATE_DEBUG_CACHE=1 reveals cache behavior in detail

References

What did you think?

Related Posts

© 2026 Vidhya Sagar Thakur. All rights reserved.