Vercel Logo

Your product page loads quickly but shows yesterday's prices. Your dashboard is always fresh but slow to render. The old mental model said "pick SSG or SSR." Cache Components instead lets a route combine a prerendered shell, cached shared content, and request-time content.

Quick Primer: What is Suspense?

This lesson uses React's <Suspense> component. Start with this small example:

Suspense is a React feature that lets you show a fallback UI while waiting for async content to load:

import { Suspense } from 'react'
 
<Suspense fallback={<Loading />}>
  <SlowComponent />  {/* Shows Loading while this resolves */}
</Suspense>
  • Purpose: Show something immediately instead of a blank screen
  • How it works: When a component inside Suspense "suspends" (awaits data), React shows the fallback
  • In Next.js: Server Components that await data automatically suspend

You'll use Suspense throughout this lesson to create request-time regions in prerendered shells. We'll cover Suspense patterns in depth in Lesson 3.3: Suspense and Streaming.

Before You Enable cacheComponents

Enabling cacheComponents: true in next.config.ts changes how Next.js handles prerendering. Code that uses Date.now(), new Date(), or performance.now() in Server Components will fail with prerender errors because these return different values at build time vs request time.

Recommended solutions (in order of preference):

  1. Wrap in Suspense + "use cache" (best for most cases):
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
 
async function Timestamp() {
  "use cache"
  cacheLife('seconds') // Refresh every 30 seconds
  return <p>Generated at {Date.now()}</p>
}
 
export default function Page() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Timestamp />
    </Suspense>
  )
}
  1. Move to client component (for real-time updates):
"use client"
import { useState, useEffect } from 'react'
 
export function LiveTimestamp() {
  const [time, setTime] = useState<number | null>(null)
  
  useEffect(() => {
    setTime(Date.now())
    const interval = setInterval(() => setTime(Date.now()), 1000)
    return () => clearInterval(interval)
  }, [])
  
  return <p>Current time: {time}</p>
}
  1. Use connection() (only for entire-page-dynamic scenarios):
import { connection } from 'next/server'
 
// Use ONLY when the entire page must be dynamic
// (e.g., CSP nonces, all content is user-specific)
export default async function Page() {
  await connection() // Opts entire route into dynamic rendering
  return <p>Generated at {Date.now()}</p>
}

See the Troubleshooting section for detailed examples.

connection() is Often an Anti-Pattern

Using connection() opts the entire route into dynamic rendering, losing all caching benefits. Only use it when:

  • The entire page must be dynamic (CSP nonces, all user-specific content)
  • Nothing on the page is cacheable

For partial dynamic content, use Suspense + "use cache" instead. See the connection() Decision Tree below.

Outcome

A product page with an instant prefetched shell and cached data that revalidates on inventory updates, using Partial Prefetching, "use cache", cacheLife(), and cacheTag().

Fast Track

  1. Enable cacheComponents and partialPrefetching in next.config.ts
  2. Add "use cache" to expensive data functions so Next.js stores their return values
  3. Configure cacheLife() profile for time-based revalidation (e.g., stale 5min, revalidate 15min)
  4. Add cacheTag() and choose updateTag() or revalidateTag(tag, 'max') based on the freshness requirement

Prerendering with Cache Components

Cache Components is opt-in. After you enable cacheComponents: true, Next.js prerenders as much of a route as it can into HTML and an RSC payload (the serialized Server Component tree). The shell can include:

  • Page layout and UI structure
  • Non-dynamic content (text, images, static imports)
  • Everything outside Suspense boundaries

Request APIs define request-time boundaries:

import { cookies, headers } from 'next/headers'
 
async function UserGreeting() {
  const cookieStore = await cookies()
  const userId = cookieStore.get('userId')?.value
  
  // User-specific data, always fresh
  const user = await fetchUser(userId)
  
  return <h1>Welcome, {user.name}!</h1>
}

Reading cookies(), headers(), or searchParams requires request-time work. Put that subtree behind <Suspense> so the surrounding shell can still prerender. Do not put request APIs inside a "use cache" scope; read them first and pass only the serializable value a cached function needs.

Suspense boundaries create "dynamic holes":

export default function ProductPage() {
  return (
    <div>
      {/* Static shell - prerenders */}
      <Header />
      <ProductLayout>
        
        {/* Dynamic hole - streams at runtime */}
        <Suspense fallback={<Skeleton />}>
          <ProductDetails />
        </Suspense>
        
      </ProductLayout>
      <Footer />
    </div>
  )
}

The static shell loads instantly. Dynamic content inside Suspense boundaries streams when ready. This is PPR (Partial Prerendering), a rendering strategy that combines static and dynamic content in a single route.

Next.js 16.3: Instant Navigations

Next.js 16.3 adds opt-in Partial Prefetching. With both cacheComponents and partialPrefetching enabled, Next.js can prefetch a reusable loading shell from your route, show it immediately after a click, and stream the remaining dynamic content.

Use the Next.js DevTools Instant Insights and Navigation Inspector to find blocking navigations and preview the shell a user will see. Learn more in the Instant Navigations guide.

"use cache" Directive: Cache Expensive Data

The "use cache" directive stores the return value of async functions, components, or entire files. A cache miss still runs the function to produce a new value.

File-level caching:

apps/web/src/lib/products.ts
"use cache"
 
// All exports in this file are cached
export async function getProducts() {
  const res = await fetch('https://api.acme.com/products')
  return res.json()
}
 
export async function getProductById(id: string) {
  const res = await fetch(`https://api.acme.com/products/${id}`)
  return res.json()
}

Function-level caching:

apps/web/src/app/products/[id]/page.tsx
import { cacheLife } from 'next/cache'
 
async function getProduct(id: string) {
  "use cache"
  cacheLife('products') // Use "products" profile from next.config.js
  
  const res = await fetch(`https://api.acme.com/products/${id}`)
  return res.json()
}
 
export default async function ProductPage({ 
  params 
}: { 
  params: Promise<{ id: string }> 
}) {
  const { id } = await params
  const product = await getProduct(id)
  
  return (
    <div>
      <h1>{product.name}</h1>
      <p>Price: ${product.price}</p>
      <p>Stock: {product.inventory}</p>
    </div>
  )
}

Component-level caching:

async function ProductGrid({ category }: { category: string }) {
  "use cache"
  cacheLife('products')
  
  const products = await fetchProductsByCategory(category)
  
  return (
    <div className="grid">
      {products.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  )
}
Cannot Use Dynamic APIs Inside 'use cache'

You cannot call cookies(), headers(), searchParams, or other dynamic APIs inside a cached scope. Extract them outside and pass as props:

// ❌ BAD - dynamic API inside cache
async function getUserData() {
  "use cache"
  const cookieStore = await cookies() // ERROR!
  return fetchUser(cookieStore.get('userId'))
}
 
// ✅ GOOD - extract outside, pass as prop
async function getUserData(userId: string) {
  "use cache"
  return fetchUser(userId)
}
 
export default async function Page() {
  const cookieStore = await cookies()
  const userId = cookieStore.get('userId')?.value
  const user = await getUserData(userId!) // Pass as argument
  
  return <h1>{user.name}</h1>
}

cacheLife(): Time-Based Revalidation

Define cache profiles in next.config.js to control how long cached data stays fresh and when it revalidates:

apps/web/next.config.ts
import type { NextConfig } from 'next'
 
const config: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true, // Enable Next.js 16.3 Instant Navigations
  cacheLife: {
    // Blog posts - longer cache, updates are rare
    blog: {
      stale: 3600, // 1 hour fresh
      revalidate: 86400, // 24 hours before revalidation
      expire: 604800, // 1 week max before forced regeneration
    },
    // Product catalog - moderate cache
    products: {
      stale: 300, // 5 minutes fresh
      revalidate: 900, // 15 minutes before revalidation
      expire: 3600, // 1 hour max
    },
    // Real-time data - minimal cache
    social: {
      stale: 60, // 1 minute fresh
      revalidate: 300, // 5 minutes before revalidation
      expire: 600, // 10 minutes max
    },
  },
}
 
export default config

Profile semantics:

  • stale: Data is fresh for this duration (served from cache, no revalidation)
  • revalidate: After this period, revalidate in the background using stale-while-revalidate (serve cached content while fetching fresh data in the background)
  • expire: Maximum time before forced synchronous regeneration

Use profiles in functions:

import { cacheLife } from 'next/cache'
 
async function getBlogPost(slug: string) {
  "use cache"
  cacheLife('blog') // Use blog profile
  
  const post = await fetchBlogPost(slug)
  return post
}
 
async function getProducts() {
  "use cache"
  cacheLife('products') // Use products profile
  
  const products = await fetchProducts()
  return products
}

cacheTag(), updateTag(), and revalidateTag()

Tag cache entries to invalidate them after mutations (create, update, delete):

apps/web/src/lib/products.ts
import { cacheLife, cacheTag } from 'next/cache'
 
async function getProduct(id: string) {
  "use cache"
  cacheLife('products')
  cacheTag('products', `product-${id}`) // Tag this cache entry
  
  const res = await fetch(`https://api.acme.com/products/${id}`)
  return res.json()
}

Choose invalidation behavior deliberately:

  • updateTag(tag) immediately expires tagged data and is available only in Server Actions. Use it when the user must see their own write on the next render.
  • revalidateTag(tag, 'max') marks tagged data stale and refreshes it on a later visit with stale-while-revalidate behavior. It works in Server Actions and Route Handlers and fits catalogs, webhooks, and background refreshes.
  • The single-argument revalidateTag(tag) form is deprecated. It still represents the old immediate-expiry behavior; migrate that intent to updateTag() in a Server Action or choose an explicit cache-life profile.

Immediately reflect a Server Action mutation:

apps/web/src/app/actions/products.ts
"use server"
 
import { updateTag } from 'next/cache'
 
export async function updateProduct(id: string, data: ProductData) {
  // Update in database
  await db.products.update({ where: { id }, data })
  
  // The author sees the updated detail and list immediately.
  updateTag(`product-${id}`)
  updateTag('products')
  
  return { success: true }
}
 
export async function createProduct(data: ProductData) {
  await db.products.create({ data })
  
  // Only invalidate list, no specific product to invalidate
  updateTag('products')
  
  return { success: true }
}

Call from client components:

apps/web/src/components/product-form.tsx
"use client"
 
import { updateProduct } from '@/app/actions/products'
 
export function ProductForm({ product }: { product: Product }) {
  async function handleSubmit(formData: FormData) {
    const result = await updateProduct(product.id, {
      name: formData.get('name') as string,
      price: Number(formData.get('price')),
    })
    
    // The Server Action expires the affected tags with updateTag().
    if (result.success) {
      alert('Product updated!')
    }
  }
  
  return (
    <form action={handleSubmit}>
      <input name="name" defaultValue={product.name} />
      <input name="price" type="number" defaultValue={product.price} />
      <button type="submit">Save</button>
    </form>
  )
}

PPR: Place Suspense Close to Dynamic Content

PPR (Partial Prerendering) maximizes the static shell by placing Suspense boundaries close to dynamic content:

apps/web/src/app/products/[id]/page.tsx
import { Suspense } from 'react'
import { cacheLife, cacheTag } from 'next/cache'
 
async function ProductDetails({ id }: { id: string }) {
  "use cache"
  cacheLife('products')
  cacheTag('products', `product-${id}`)
  
  const product = await fetchProduct(id)
  return (
    <div>
      <h2>{product.name}</h2>
      <p>${product.price}</p>
    </div>
  )
}
 
async function ProductReviews({ id }: { id: string }) {
  // No cache - always fetch fresh reviews
  const reviews = await fetchReviews(id)
  return (
    <div>
      {reviews.map(r => (
        <div key={r.id}>{r.comment}</div>
      ))}
    </div>
  )
}
 
export default async function ProductPage({ 
  params 
}: { 
  params: Promise<{ id: string }> 
}) {
  const { id } = await params
  
  return (
    <div>
      {/* Static shell - prerenders instantly */}
      <header>Product Page</header>
      
      {/* Cached product data - loads fast from cache */}
      <Suspense fallback={<div>Loading product...</div>}>
        <ProductDetails id={id} />
      </Suspense>
      
      {/* Fresh reviews - streams after product */}
      <Suspense fallback={<div>Loading reviews...</div>}>
        <ProductReviews id={id} />
      </Suspense>
      
      <footer>Footer content</footer>
    </div>
  )
}

Result:

  1. Static shell (header, layout, footer) loads instantly
  2. Cached product data loads from cache (fast)
  3. Fresh reviews stream when ready (dynamic)

Decision Framework

┌─────────────────────────────────────────────────────────────┐
│                    CACHE DECISION TREE                      │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Is the data user-specific?                                 │
│  ├─ YES → Keep dynamic (no cache)                          │
│  └─ NO → Continue...                                        │
│                                                             │
│  Is staleness acceptable?                                   │
│  ├─ NO → Keep dynamic                                       │
│  └─ YES → Use "use cache"                                   │
│                                                             │
│  How should it revalidate?                                  │
│  ├─ On schedule → cacheLife() with time profile            │
│  └─ Own write → cacheTag() + updateTag()                   │
│  └─ Background refresh → revalidateTag(tag, 'max')         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Examples:

Data TypeUser-Specific?Staleness OK?Strategy
User dashboardYesN/ADynamic (no cache)
Product catalogNoYes (5-15min)"use cache" + cacheLife('products')
Blog postsNoYes (1-24hr)"use cache" + cacheLife('blog')
Live sports scoresNoNoDynamic (no cache)
Product detailNoYes, with invalidation"use cache" + cacheTag() + updateTag() after an edit
Prompt: Design Caching Strategy for My Data
I'm implementing Cache Components in Next.js 16 and need help deciding on a caching strategy.
 
<context>
Next.js 16 Cache Components offer:
- `"use cache"` directive for shared caching
- `"use cache: private"` for per-user caching
- `cacheLife()` for time-based expiration (profiles: blog, products, social, etc.)
- `cacheTag()` + `updateTag()` for immediate mutation visibility
- `cacheTag()` + `revalidateTag(tag, 'max')` for stale-while-revalidate refreshes
</context>
 
<my-data>
**Data type:** _____
Example: "Product catalog", "User dashboard", "Blog posts", "Live sports scores"
 
**How often it changes:** _____
Example: "Every few hours", "When admin updates", "Real-time", "Once a day"
 
**Is it user-specific?**
- [ ] Same for all users (shared)
- [ ] Different per user (personalized)
 
**Staleness tolerance:** _____
Example: "5 minutes is fine", "Must be real-time", "1 hour acceptable"
 
**What triggers updates:** _____
Example: "Admin publishes", "User makes purchase", "External API webhook"
</my-data>
 
<current-implementation>
```tsx
// My current data fetching:
___PASTE_YOUR_DATA_FETCHING_CODE___
```
</current-implementation>
 
**Questions:**
1. Should I cache this data at all?
2. Which cacheLife profile fits? (blog, products, social, or custom)
3. Should I use time-based (cacheLife) or mutation-based (cacheTag) invalidation?
4. Shared cache or per-user cache?
 
Generate a complete caching implementation with the appropriate directives and invalidation strategy. Explain why each choice fits my data.

Edge Cases and Gotchas

Understanding these edge cases prevents debugging headaches:

Edge Runtime Not Supported

"use cache" requires the Node.js runtime. Edge Runtime rejects the directive:

// ❌ FAILS - Edge Runtime doesn't support "use cache"
export const runtime = 'edge'
 
async function getData() {
  "use cache"  // Error: "use cache" is not supported in Edge Runtime
  return fetch('https://api.example.com/data')
}

Cache Components require the Node.js runtime. If a route must use the Edge Runtime, do not enable Cache Components for that application; use an external cache or provider-specific caching appropriate to that runtime instead.

Serialization Required

All arguments and return values must be serializable (convertible to JSON format, meaning only primitive values, arrays, and plain objects):

// ❌ FAILS - Functions aren't serializable
async function getData(callback: () => void) {
  "use cache"
  callback() // Error: callback is not serializable
  return fetch('/api/data')
}
 
// ❌ FAILS - Symbols aren't serializable
async function getData(config: { [Symbol.for('key')]: string }) {
  "use cache"
  return fetch('/api/data')
}
 
// ✅ WORKS - Primitive arguments and plain objects
async function getData(id: string, options: { limit: number }) {
  "use cache"
  return fetch(`/api/data/${id}?limit=${options.limit}`)
}

Nested Caching Behavior

When cached functions call other cached functions, the innermost cacheLife() wins unless the outer function also specifies one:

// Outer cache without cacheLife → inner controls timing
async function outer() {
  "use cache"
  return inner()  // inner's cacheLife wins
}
 
async function inner() {
  "use cache"
  cacheLife('hours')  // This controls the cache timing
  return fetch('/api/data')
}
 
// Outer cache WITH cacheLife → outer wins
async function outerWithLife() {
  "use cache"
  cacheLife('minutes')  // This overrides inner
  return inner()
}

map() Pitfall: Async Callbacks Don't Work

You cannot use "use cache" inside array callbacks like map():

// ❌ FAILS - "use cache" inside map callback
const results = items.map(async (item) => {
  "use cache"  // Error: Directive must be at function body start
  return processItem(item)
})
 
// ✅ WORKS - Extract to named function
async function processItem(id: string) {
  "use cache"
  cacheTag(`item-${id}`)
  return fetch(`/api/items/${id}`)
}
 
const results = await Promise.all(
  items.map((item) => processItem(item.id))
)

All "use cache" Functions Must Be Async

The "use cache" directive only works with async functions:

// ❌ FAILS - Synchronous function
function getConfig() {
  "use cache"  // Error: "use cache" functions must be async
  return { theme: 'dark' }
}
 
// ✅ WORKS - Async function
async function getConfig() {
  "use cache"
  return { theme: 'dark' }
}

cacheTag Limits

Tags have size and count limits:

  • Maximum tag length: 256 characters
  • Maximum tags per entry: 128 tags
// ❌ FAILS - Tag too long
cacheTag('a'.repeat(300))  // Error: Tag exceeds 256 characters
 
// ❌ FAILS - Too many tags
cacheTag(...Array(200).fill('tag'))  // Error: Exceeds 128 tags
 
// ✅ WORKS - Use hierarchical tags
cacheTag('products', `product-${id}`, `category-${categoryId}`)

When to Use connection()

┌─────────────────────────────────────────────────────────────┐
│               connection() DECISION TREE                     │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  Does the ENTIRE page need to be dynamic?                   │
│  ├─ NO → Use Suspense + "use cache" for partial caching    │
│  └─ YES → Continue...                                       │
│                                                             │
│  Is there ANY cacheable content on the page?                │
│  ├─ YES → Use Suspense to isolate dynamic parts            │
│  └─ NO → Continue...                                        │
│                                                             │
│  Why is the entire page dynamic?                            │
│  ├─ CSP nonces → connection() ✓                            │
│  ├─ All user-specific → connection() ✓                     │
│  ├─ Realtime updates → Client Component ✓                  │
│  └─ "It's easier" → Suspense + "use cache" ✓               │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Valid uses of connection():

  • CSP nonces that must be unique per request
  • Pages where 100% of content is user-specific
  • Webhook handlers that must process every request

Anti-pattern uses of connection():

  • "Quick fix" for Date.now() errors
  • Pages with any cacheable content
  • Avoiding Suspense boundaries

Cache Variants

Next.js 16 supports three cache variants for different use cases:

VariantScopeUse Case
"use cache"Shared across all usersPublic data (products, blog posts)
"use cache: private"Per-user cacheUser-specific but cacheable (dashboard)
"use cache: remote"Remote cache (Vercel)Distributed caching across regions
apps/web/src/lib/data.ts
// Public data - shared cache (default)
async function getProducts() {
  "use cache"
  return fetch('/api/products')
}
 
// User-specific but cacheable
async function getUserDashboard(userId: string) {
  "use cache: private"
  cacheTag(`user-${userId}`)
  return fetch(`/api/users/${userId}/dashboard`)
}
 
// Distributed cache (Vercel infrastructure)
async function getGlobalConfig() {
  "use cache: remote"
  return fetch('/api/config')
}
Do Not Put Request APIs in a Shared Cache

Read cookies(), headers(), and searchParams outside a shared "use cache" scope. If caching personalized data is appropriate, use the documented private-cache semantics and test navigation, invalidation, and isolation with the exact Next.js version you deploy. Do not simulate a private cache by placing a user identifier in a shared cache tag.

Hands-On Exercise 3.1

Requirements:

  1. Enable cacheComponents and partialPrefetching in next.config.ts
  2. Add "use cache" to a product data function in apps/web/src/lib/products.ts
  3. Configure a products cacheLife profile in next.config.ts (stale: 5min, revalidate: 15min, expire: 1hr)
  4. Add cacheTag() to tag product cache entries with product-${id} and products
  5. Create a Server Action in apps/web/src/app/actions/products.ts that updates a product and calls updateTag()
  6. Test cache and navigation behavior with the Network tab and Next.js DevTools

Implementation hints:

  • Use cacheLife('products') inside the cached function, after the "use cache" directive
  • cacheTag() accepts multiple tags: cacheTag('products', 'product-123')
  • Server Actions must have "use server" at the top
  • Call updateTag(tag) after a user mutation when the next render must show the write immediately
  • Reserve revalidateTag(tag, 'max') for stale-while-revalidate updates, including Route Handler webhooks
  • Verify with curl -I http://localhost:3000/products/123 to see cache headers

Try It

  1. Verify static shell prerendering:

    • Run pnpm build and check build output
    • Look for (static) vs ƒ (dynamic) indicators
    • Static routes show prerendered HTML size
  2. Test cache behavior:

    • Load product page: http://localhost:3000/products/123
    • Open Network tab, check response headers for cache status
    • Reload page, verify cached response (should be instant)
  3. Test cache invalidation:

    • Trigger updateTag('product-123') via the Server Action
    • Reload product page, verify fresh data appears
    • Check Network tab for cache miss followed by new cache entry
  4. Measure performance:

    • Use Lighthouse to measure FCP (First Contentful Paint)
    • Compare static shell (instant) vs dynamic content (streaming)

Commit & Deploy

git add -A
git commit -m "feat(advanced): implement Cache Components with cacheLife and cacheTag"
git push -u origin feat/cache-components

Done-When

  • Product page loads instantly (static shell prerendered, visible in build output)
  • Next.js DevTools reports the product navigation as instant
  • Product data shows cached values (verify cache headers in Network tab)
  • After calling updateTag(), the action's next render shows fresh data
  • Build output shows static routes with appropriate cache configuration

Troubleshooting

"Cannot use cookies() inside 'use cache'"

  • Extract cookies(), headers(), or searchParams outside the cached function
  • Pass values as function arguments instead
// ❌ BAD
async function getData() {
  "use cache"
  const cookieStore = await cookies() // ERROR
  return fetchData(cookieStore.get('token'))
}
 
// ✅ GOOD
async function getData(token: string) {
  "use cache"
  return fetchData(token)
}
 
// In page component
const cookieStore = await cookies()
const data = await getData(cookieStore.get('token')?.value!)

"revalidateTag() requires 2 arguments" or "Expected 2 arguments, but got 1"

The single-argument form is deprecated. Choose the API that matches the desired user experience:

// Deprecated immediate-expiry form
revalidateTag('products')
 
// Stale-while-revalidate; valid in Server Actions and Route Handlers
revalidateTag('products', 'max')
 
// Immediate read-your-own-writes; Server Actions only
updateTag('products')

revalidateTag(tag, 'max') marks cached data stale and refreshes it on the next visit. updateTag(tag) expires it immediately. An explicit { expire: 0 } profile remains available for advanced server cases, but it should not be taught as the default replacement for updateTag().

"Error: Route /path used Date.now() which is dynamic"

Using Date.now() or new Date() in Server Components causes prerender errors because timestamps are inherently dynamic:

// ❌ Causes prerender error
export default async function Page() {
  const timestamp = Date.now() // ERROR during build
  return <p>Generated at {timestamp}</p>
}

Solutions:

  1. Wrap in "use cache" (caches the timestamp at cache creation time):
async function getTimestamp() {
  "use cache"
  return Date.now() // Cached when cache entry is created
}
  1. Use connection() to opt into dynamic rendering:
import { connection } from 'next/server'
 
export default async function Page() {
  await connection() // Opts into dynamic rendering
  const timestamp = Date.now() // Now allowed
  return <p>Generated at {timestamp}</p>
}
  1. Move to client component (if you need real-time updates):
"use client"
import { useState, useEffect } from 'react'
 
export function LiveTimestamp() {
  const [time, setTime] = useState<number | null>(null)
  
  useEffect(() => {
    setTime(Date.now())
  }, [])
  
  return <p>Current time: {time}</p>
}

"cacheComponents is not a valid experimental option"

In Next.js 16.3, cacheComponents remains a top-level config option:

apps/web/next.config.ts
// ❌ Old config (Next.js 16.0.x)
const config: NextConfig = {
  experimental: {
    cacheComponents: true, // No longer valid here
  },
}
 
// ✅ Current config (Next.js 16.3)
const config: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true,
}

"I still see stale data after revalidateTag()"

  • Check tag spelling matches exactly (case-sensitive)
  • Verify cacheTag() was called in the cached function
  • Ensure revalidateTag(tag, 'max') is called AFTER the mutation succeeds
  • Remember that 'max' intentionally permits stale content while refreshing on a later visit
  • If a Server Action must show its own write immediately, use updateTag(tag) instead

"The route has no useful prerendered shell"

  • Move <Suspense> closer to the request-time content
  • Check whether cookies(), headers(), or searchParams are read above the boundary
  • Move shared work into an explicit "use cache" scope; do not combine Cache Components with dynamic = 'force-static'
  • Inspect the route with Next.js DevTools and production build output

"Cache not working in development"

  • Cache behavior differs in dev vs build mode
  • Run pnpm build && pnpm start to test production caching
  • Development mode bypasses some caching for fast refresh

Solution

Click to reveal solution
apps/web/next.config.ts
import type { NextConfig } from 'next'
 
const config: NextConfig = {
  cacheComponents: true,
  partialPrefetching: true, // Enable Next.js 16.3 Instant Navigations
  cacheLife: {
    blog: {
      stale: 3600, // 1 hour
      revalidate: 86400, // 24 hours
      expire: 604800, // 1 week
    },
    products: {
      stale: 300, // 5 minutes
      revalidate: 900, // 15 minutes
      expire: 3600, // 1 hour
    },
    social: {
      stale: 60, // 1 minute
      revalidate: 300, // 5 minutes
      expire: 600, // 10 minutes
    },
  },
}
 
export default config
apps/web/src/lib/products.ts
"use cache"
 
import { cacheLife, cacheTag } from 'next/cache'
 
/**
 * Fetch a single product by ID with caching.
 * Cache invalidated via updateTag() after user mutations.
 */
export async function getProduct(id: string) {
  cacheLife('products') // 5min fresh, 15min revalidate, 1hr expire
  cacheTag('products', `product-${id}`) // Tag for invalidation
  
  const res = await fetch(`https://api.acme.com/products/${id}`)
  if (!res.ok) throw new Error('Failed to fetch product')
  
  return res.json() as Promise<Product>
}
 
/**
 * Fetch all products with caching.
 */
export async function getProducts() {
  cacheLife('products')
  cacheTag('products') // Invalidate when any product changes
  
  const res = await fetch('https://api.acme.com/products')
  if (!res.ok) throw new Error('Failed to fetch products')
  
  return res.json() as Promise<Product[]>
}
 
interface Product {
  id: string
  name: string
  price: number
  inventory: number
}
apps/web/src/app/products/[id]/page.tsx
import { getProduct } from '@/lib/products'
import { Suspense } from 'react'
 
export default async function ProductPage({ 
  params 
}: { 
  params: Promise<{ id: string }> 
}) {
  const { id } = await params
  
  return (
    <div>
      <header>
        <h1>Product Details</h1>
      </header>
      
      <Suspense fallback={<ProductSkeleton />}>
        <ProductDetails id={id} />
      </Suspense>
      
      <footer>ACME Corp</footer>
    </div>
  )
}
 
async function ProductDetails({ id }: { id: string }) {
  const product = await getProduct(id)
  
  return (
    <div>
      <h2>{product.name}</h2>
      <p>Price: ${product.price}</p>
      <p>In Stock: {product.inventory}</p>
    </div>
  )
}
 
function ProductSkeleton() {
  return (
    <div className="animate-pulse">
      <div className="h-8 w-48 bg-gray-200 rounded mb-4" />
      <div className="h-6 w-32 bg-gray-200 rounded mb-2" />
      <div className="h-6 w-24 bg-gray-200 rounded" />
    </div>
  )
}
apps/web/src/app/actions/products.ts
"use server"
 
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
 
export async function updateProduct(
  id: string, 
  data: { name?: string; price?: number; inventory?: number }
) {
  try {
    // Update in database
    await db.products.update({
      where: { id },
      data,
    })
    
    // The next render observes the successful write.
    updateTag(`product-${id}`)
    updateTag('products')
    
    return { success: true }
  } catch (error) {
    console.error('Failed to update product:', error)
    return { success: false, error: 'Failed to update product' }
  }
}
 
export async function deleteProduct(id: string) {
  try {
    await db.products.delete({ where: { id } })
    
    updateTag(`product-${id}`)
    updateTag('products')
    
    return { success: true }
  } catch (error) {
    console.error('Failed to delete product:', error)
    return { success: false, error: 'Failed to delete product' }
  }
}
apps/web/src/components/product-form.tsx
"use client"
 
import { updateProduct } from '@/app/actions/products'
import { useState } from 'react'
 
export function ProductForm({ product }: { product: Product }) {
  const [loading, setLoading] = useState(false)
  const [message, setMessage] = useState('')
  
  async function handleSubmit(formData: FormData) {
    setLoading(true)
    setMessage('')
    
    const result = await updateProduct(product.id, {
      name: formData.get('name') as string,
      price: Number(formData.get('price')),
      inventory: Number(formData.get('inventory')),
    })
    
    setLoading(false)
    
    if (result.success) {
      setMessage('Product updated! Cache invalidated.')
    } else {
      setMessage(result.error || 'Failed to update')
    }
  }
  
  return (
    <form action={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="name">Name</label>
        <input 
          id="name"
          name="name" 
          defaultValue={product.name}
          className="w-full border rounded px-3 py-2"
        />
      </div>
      
      <div>
        <label htmlFor="price">Price</label>
        <input 
          id="price"
          name="price" 
          type="number" 
          defaultValue={product.price}
          className="w-full border rounded px-3 py-2"
        />
      </div>
      
      <div>
        <label htmlFor="inventory">Inventory</label>
        <input 
          id="inventory"
          name="inventory" 
          type="number" 
          defaultValue={product.inventory}
          className="w-full border rounded px-3 py-2"
        />
      </div>
      
      <button 
        type="submit" 
        disabled={loading}
        className="bg-blue-500 text-white px-4 py-2 rounded"
      >
        {loading ? 'Saving...' : 'Save Changes'}
      </button>
      
      {message && (
        <p className={message.includes('success') ? 'text-green-600' : 'text-red-600'}>
          {message}
        </p>
      )}
    </form>
  )
}
 
interface Product {
  id: string
  name: string
  price: number
  inventory: number
}
Cache Components vs Old Rendering Strategies

The old mental model focused on "choosing a rendering mode" per page: SSG (Static Site Generation, pages built at build time), ISR (Incremental Static Regeneration, static pages that revalidate), SSR (Server-Side Rendering, pages rendered on each request), or CSR (Client-Side Rendering, pages rendered in the browser). The new model:

  • Cache Components is opt-in and prerenders the stable portion of a route
  • Request APIs need a Suspense boundary so the surrounding shell can remain reusable
  • "use cache" stores a scoped return value
  • Revalidation is explicit (cacheLife for time, updateTag for own writes, and revalidateTag for background freshness)

This gives you fine-grained control: static shell for instant loads + cached/dynamic content where needed.

References

Was this helpful?

supported.