---
title: "Cache Components for Instant and Fresh Pages"
description: "Use Cache Components to prerender static shells instantly while serving fresh or cached dynamic data. Control revalidation with cacheLife() for time-based updates or cacheTag() for on-demand invalidation."
canonical_url: "https://vercel.com/academy/nextjs-foundations/cache-components"
md_url: "https://vercel.com/academy/nextjs-foundations/cache-components.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-08-21T03:37:31.750Z"
content_type: "lesson"
course: "nextjs-foundations"
course_title: "Next.js Foundations"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Cache Components for Instant and Fresh Pages

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.

\*\*Note: 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:

```tsx
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](/03-advanced-patterns/03-suspense-and-streaming).

\*\*Warning: 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):

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

2. **Move to client component** (for real-time updates):

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

3. **Use `connection()`** (only for entire-page-dynamic scenarios):

```tsx
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](#troubleshooting) section for detailed examples.

\*\*Note: 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](#when-to-use-connection) 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:**

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

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

\*\*Note: 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](https://nextjs.org/docs/app/guides/instant-navigation).

## "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:**

```typescript title="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:**

```typescript title="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:**

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

\*\*Warning: 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:

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

```typescript title="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:**

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

```typescript title="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:**

```typescript title="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:**

```typescript title="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**:

```typescript title="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 Type          | User-Specific? | Staleness OK?          | Strategy                                             |
| ------------------ | -------------- | ---------------------- | ---------------------------------------------------- |
| User dashboard     | Yes            | N/A                    | Dynamic (no cache)                                   |
| Product catalog    | No             | Yes (5-15min)          | "use cache" + cacheLife('products')                  |
| Blog posts         | No             | Yes (1-24hr)           | "use cache" + cacheLife('blog')                      |
| Live sports scores | No             | No                     | Dynamic (no cache)                                   |
| Product detail     | No             | Yes, with invalidation | "use cache" + cacheTag() + updateTag() after an edit |

````markdown title="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:

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

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

```typescript
// 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()`:

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

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

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

| Variant                | Scope                   | Use Case                                |
| ---------------------- | ----------------------- | --------------------------------------- |
| `"use cache"`          | Shared across all users | Public data (products, blog posts)      |
| `"use cache: private"` | Per-user cache          | User-specific but cacheable (dashboard) |
| `"use cache: remote"`  | Remote cache (Vercel)   | Distributed caching across regions      |

```typescript title="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')
}
```

\*\*Warning: 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

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

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

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

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

```typescript
async function getTimestamp() {
  "use cache"
  return Date.now() // Cached when cache entry is created
}
```

2. **Use `connection()` to opt into dynamic rendering:**

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

3. **Move to client component** (if you need real-time updates):

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

```typescript title="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

\*\*Side Quest: Cache Profiling\*\*

## Solution

Click to reveal solution

```typescript title="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
```

```typescript title="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
}
```

```typescript title="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>
  )
}
```

```typescript title="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' }
  }
}
```

```typescript title="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
}
```

\*\*Note: 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

- [Next.js Caching and Revalidating](https://nextjs.org/docs/app/getting-started/caching-and-revalidating)
- [use cache Directive](https://nextjs.org/docs/app/api-reference/directives/use-cache)
- [cacheLife API](https://nextjs.org/docs/app/api-reference/config/next-config-js/cacheLife)
- [Revalidating with Cache Components](https://nextjs.org/docs/app/getting-started/revalidating)
- [updateTag](https://nextjs.org/docs/app/api-reference/functions/updateTag)
- [revalidateTag](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
- [connection Function](https://nextjs.org/docs/app/api-reference/functions/connection) (opt into dynamic rendering)
- [Partial Prerendering (PPR)](https://nextjs.org/docs/app/getting-started/partial-prerendering)


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
