Next.js 16 Caching

New Caching Model & Migration Guide

Caterpillar logo

Next.js 16 Caching Model

Understanding the New Approach

Next.js 16 introduces a redesigned caching system that provides fine-grained control over what gets cached and for how long. The new model uses function-level directives instead of route segment config exports.

Key Change: Move from route-level configuration to function-level caching directives for better granularity.

What's new in Next.js 16:

  • "use cache" directive: Explicit caching at the function or component level
  • cacheLife(): Configure revalidation times with preset or custom profiles
  • cacheTag(): Tag cached content for targeted invalidation
  • Pages are dynamic by default: Dynamic data must be wrapped in Suspense or marked with "use cache"
Critical Change: With Cache Components, you must explicitly handle dynamic data using <Suspense> or "use cache". Otherwise, you'll get a build error.
Migration Path: The old route segment config still works, but the new directives offer more granular control.

The "use cache" Directive

Fine-Grained Cache Control

The "use cache" directive is your new tool for marking components, functions, or even entire pages as cacheable. It works at multiple levels and gives you precise control.

Key Concept: "use cache" marks a function or component as cacheable — it doesn't immediately cache anything. Caching happens at build time when Next.js writes the output to the cache store. Use it strategically on expensive operations.
export async function BlogPosts() {
  "use cache"
  
  const posts = await fetchPosts()
  
  return (
    <div>
      {posts.map(post => (
        <article key={post.id}>
          <h2>{post.title}</h2>
        </article>
      ))}
    </div>
  )
}

Benefits of "use cache":

  • Granular control: Cache specific functions, not entire routes
  • Better composition: Mix cached and dynamic content easily
  • Explicit intent: Clear what's cached vs dynamic
  • Better DX: No more guessing about caching behavior
Critical behaviors to understand:
  • Page-level = ISR: Adding "use cache" to a page component makes the entire output cached (like ISR). Caches cannot be partial at the page level.
  • No streaming: ISR Pages are served as complete HTML — Suspense fallbacks will not show to users at request time.
  • Layout ≠ Page: Setting "use cache" in a layout does NOT make the page static. Each route segment controls its own caching independently.

cacheLife() - Revalidation Control

How Long Should Content Be Cached?

cacheLife() lets you configure how long cached content stays fresh. Next.js 16 provides preset profiles for common scenarios, or you can create custom configurations.

Concept: Different content has different freshness requirements. Use cacheLife to match your application needs.
import { cacheLife } from 'next/cache'

export async function BlogPosts() {
  "use cache"
  cacheLife("minutes")
  
  const posts = await fetchPosts()
  return <PostList posts={posts} />
}

export async function StaticContent() {
  "use cache"
  cacheLife("weeks")
  
  const content = await fetchStaticContent()
  return <div>{content}</div>
}

Available preset profiles:

ProfileUse CaseStaleRevalidateExpire
defaultStandard content5 min15 min1 year
secondsReal-time data30 sec1 sec1 min
minutesNews feeds, social5 min1 min1 hour
hoursProduct pages5 min1 hour1 day
daysBlog posts, articles5 min1 day1 week
weeksNewsletters, podcasts5 min1 week30 days
maxArchived, legal docs5 min30 days1 year
Client-side minimum: The client router enforces a 30-second minimum stale time, regardless of configuration.
Best Practice: Start with preset profiles. Only create custom configs when you have specific performance requirements.

cacheTag() - Targeted Cache Invalidation

Invalidate Only What Changed

cacheTag() lets you tag cached content with identifiers. When content updates, you can invalidate specific tags instead of clearing all caches.

Key Insight: When a blog post is updated, you only want to invalidate that post's cache - not every post on your site!
import { cacheTag } from 'next/cache'

export async function BlogPost({ id }: { id: string }) {
  "use cache"
  cacheTag(`blog-post-${id}`)  // Tag with specific post ID
  
  const post = await fetchBlogPost(id)
  
  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

// You can also use multiple tags
export async function ProductPage({ id }: { id: string }) {
  "use cache"
  cacheTag(`product-${id}`, 'products', 'catalog')
  
  const product = await fetchProduct(id)
  return <ProductDetails product={product} />
}

Common tagging patterns:

  • By ID: blog-post-123 for individual items
  • By category: category-electronics for groups
  • By type: products or users for entity types
  • Hierarchical: blog-post-123, blog-posts, content for flexible invalidation
Pro Tip: Use multiple tags when content belongs to different logical groups. This gives you flexibility in how you invalidate caches.

Cache Invalidation: updateTag & revalidateTag

Two Ways to Invalidate Cached Data

Next.js 16 provides two functions for cache invalidation, each optimized for different scenarios.

Key Difference: updateTag is for immediate "read-your-own-writes" in Server Actions, while revalidateTag is for background revalidation in Route Handlers and webhooks.
FeatureupdateTagrevalidateTag
Where to useServer Actions onlyServer Actions & Route Handlers
BehaviorNext request waits for fresh dataServes stale while revalidating in background
Use caseUser sees their own changes immediatelyBackground updates, webhooks
'use server'

import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createBlogPost(formData: FormData) {
  const post = await db.blogPost.create({
    data: {
      title: formData.get('title'),
      content: formData.get('content'),
    }
  })
  
  // Immediate invalidation - user will see fresh data
  updateTag('blog-posts')
  updateTag(`blog-post-${post.id}`)
  
  // User navigates to the new post - sees it immediately
  redirect(`/blog/${post.id}`)
}
Security Warning: Always protect revalidation endpoints with authentication! Use environment variables for tokens.

Migrating from Next.js 15 to 16

Step-by-Step Migration Guide

Migrating to Next.js 16's caching model involves replacing route segment config exports with the new directives. Here's how to do it systematically.

Note: The previous route segment config approach remains supported. You can migrate incrementally.

Step 1: Enable Cache Components

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
}

export default nextConfig

Quick Reference - What Changed:

Next.js 15 (Old)Next.js 16 (New)
experimental.pprcacheComponents: true
dynamic = 'force-dynamic'Remove (default behavior in Next.js 16)
dynamic = 'force-static'"use cache" + cacheLife('max')
revalidate = NcacheLife({ revalidate: N })
unstable_cache()"use cache" or "use cache: remote"

Step 2: Migrate Static Routes

// app/page.tsx
export const dynamic = 'force-static'
export const revalidate = 60

export default async function HomePage() {
  const data = await fetchData()
  return <div>{data}</div>
}

Step 3: Handle Dynamic Data

Important: With Cache Components enabled, dynamic data must be explicitly handled with either <Suspense> or "use cache".
// app/blog/page.tsx
export const dynamic = 'force-dynamic'

export default async function BlogPage() {
  const posts = await fetchPosts()
  return <PostList posts={posts} />
}

Key difference: Without explicit handling, you'll get a build error: "Uncached data was accessed outside of Suspense".

Mixed Caching Strategies

Combining Static, Cached, and Dynamic

The real power of Cache Components is mixing static, cached, and dynamic content in a single page. Each piece can have its own caching strategy.

Strategy: Static headers, cached stable data, and dynamic personalized content - all in one page with optimal performance.
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife } from 'next/cache'

export default function BlogPage() {
  return (
    <>
      {/* 1. Static content - prerendered automatically */}
      <header>
        <h1>Our Blog</h1>
        <nav>Home | About | Contact</nav>
      </header>

      {/* 2. Cached content - included in static shell */}
      <BlogPosts />

      {/* 3. Dynamic content - streams at request time */}
      <Suspense fallback={<p>Loading preferences...</p>}>
        <UserPreferences />
      </Suspense>
    </>
  )
}

// Cached: Same for all users
async function BlogPosts() {
  "use cache"
  cacheLife("hours")
  
  const posts = await fetchPosts()
  
  return (
    <section>
      <h2>Latest Posts</h2>
      {posts.map(post => (
        <article key={post.id}>
          <h3>{post.title}</h3>
        </article>
      ))}
    </section>
  )
}

// Dynamic: Personalized per user
async function UserPreferences() {
  const theme = (await cookies()).get('theme')?.value
  
  return (
    <aside>
      <p>Your theme: {theme}</p>
    </aside>
  )
}

Three content types:

  • Static: Header - prerendered automatically, no directive needed
  • Cached: BlogPosts - marked with "use cache", included in static shell
  • Dynamic: UserPreferences - wrapped in Suspense, streams at request time
Best Practice: Place Suspense boundaries as close to dynamic components as possible to maximize static shell content.

Runtime Data Constraints

What You Can't Do Inside "use cache"

Functions and components marked with "use cache" cannot access runtime APIs like cookies(), headers(), or searchParams. Cached output must be deterministic and shareable across all users.

Common Mistake: Trying to read cookies or headers inside a cached function will cause a build error.
async function CachedProfile() {
  "use cache"
  // ❌ Error! Cannot access cookies inside "use cache"
  const session = (await cookies()).get('session')?.value
  const data = await fetchUserData(session)
  return <div>{data.name}</div>
}

How cache keys are generated:

  • Build ID: All caches invalidate automatically on deploy
  • Function ID: Hash of the function's location in code
  • Arguments: Props and function arguments become part of the cache key
  • Closure variables: Outer scope values captured by the function are included
Best Practice: Extract runtime data (cookies, headers) in a parent component, then pass it as props to cached children. The prop values automatically become part of the cache key, giving you per-user caching without breaking the rules.
Exception: Use "use cache: private" when you need to access runtime APIs directly inside a cached function (covered in advanced slides).

Custom Cache Profiles

Reusable Cache Configurations

For complex applications, you can define custom cache profiles in next.config.ts and reuse them across your app.

import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  cacheComponents: true,
  cacheLife: {
    'ecommerce-product': {
      stale: 300,      // 5 minutes fresh
      revalidate: 900,  // Revalidate after 15 min
      expire: 3600,     // Hard expire after 1 hour
    },
    'user-content': {
      stale: 0,         // Always revalidate
      revalidate: 60,   // Check every minute
      expire: 300,      // Expire after 5 minutes
    },
    'static-assets': {
      stale: 31536000,  // Fresh for 1 year
      revalidate: false, // Never revalidate
      expire: false,    // Never expire
    }
  }
}

export default nextConfig

When to create custom profiles:

  • Consistent patterns: Same cache behavior used across multiple places
  • Complex requirements: Need specific stale/expire timings
  • Team alignment: Enforce caching standards across the team
  • Environment-specific: Different cache times for dev/prod

Common Patterns & Best Practices

Real-World Caching Strategies

Let's look at proven patterns that work well in production applications.

Pattern 1: Cached Product + Dynamic Inventory

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

// Product data cached for hours
async function ProductInfo({ id }: { id: string }) {
  "use cache"
  cacheLife("hours")
  cacheTag(`product-${id}`, 'products')
  
  const product = await db.product.findUnique({ where: { id } })
  
  return <ProductDetails product={product} />
}

// Inventory dynamic (real-time)
async function LiveInventory({ id }: { id: string }) {
  const inventory = await db.inventory.findUnique({ 
    where: { productId: id } 
  })
  
  return <InventoryBadge stock={inventory.quantity} />
}

export default async function ProductPage({ params }) {
  const { id } = await params
  
  return (
    <div>
      {/* Cached product info */}
      <ProductInfo id={id} />
      
      {/* Dynamic inventory wrapped in Suspense */}
      <Suspense fallback={<div>Loading stock...</div>}>
        <LiveInventory id={id} />
      </Suspense>
    </div>
  )
}

Pattern 2: Shared Config + Personal Data

import { Suspense } from 'react'
import { cacheLife } from 'next/cache'

// Shared widgets cached
async function WidgetLayout() {
  "use cache"
  cacheLife("days")
  
  const config = await fetchWidgetConfig()
  
  return (
    <div className="dashboard-layout">
      <h1>{config.title}</h1>
      {/* Layout structure */}
    </div>
  )
}

// User data NOT cached (personal)
async function UserData({ userId }: { userId: string }) {
  const userData = await db.user.findUnique({
    where: { id: userId },
    include: { stats: true, activity: true }
  })
  
  return (
    <>
      <UserStats data={userData.stats} />
      <ActivityFeed items={userData.activity} />
    </>
  )
}

export default function Dashboard({ userId }: { userId: string }) {
  return (
    <div>
      {/* Cached shared layout */}
      <WidgetLayout />
      
      {/* Dynamic user data wrapped in Suspense */}
      <Suspense fallback={<div>Loading your data...</div>}>
        <UserData userId={userId} />
      </Suspense>
    </div>
  )
}

Key Takeaways:

  • Static content: Headers, navigation - automatically prerendered
  • Cached content: Mark with "use cache" + cacheLife for shared data
  • Dynamic content: Wrap in Suspense for personalized or real-time data
  • All dynamic data must be explicit: Either cached or in Suspense - no exceptions
Critical: Dynamic data without Suspense or "use cache" will cause a build error. This is intentional to prevent accidental blocking.

Advanced: "use cache: remote"

Caching Shared Data in Dynamic Contexts

Regular "use cache" doesn't work after calling connection(). Use "use cache: remote" to cache shared data in these dynamic contexts.

Use Case: Cache expensive operations (DB queries, API calls) that run at request time but can be shared across all users.
import { Suspense } from 'react'
import { connection } from 'next/server'
import { cacheLife, cacheTag } from 'next/cache'

async function ProductPrice({ id }: { id: string }) {
  // Make context dynamic - price fetched per request
  await connection()
  
  const price = await getPrice(id)
  return <div>Price: ${price}</div>
}

async function getPrice(productId: string) {
  "use cache: remote"
  cacheTag(`product-price-${productId}`)
  cacheLife({ expire: 3600 }) // 1 hour
  
  // This query is cached in remote handler
  // Shared across all users to reduce DB load
  return db.products.getPrice(productId)
}

export default function ProductPage({ params }) {
  return (
    <div>
      <h1>Product Details</h1>
      <Suspense fallback={<div>Loading price...</div>}>
        <ProductPrice id={params.id} />
      </Suspense>
    </div>
  )
}

Key characteristics:

  • Works in dynamic contexts: After connection() where regular cache fails
  • Shared globally: Same cached data for all users
  • Server-side storage: Stored in remote cache handlers (Redis, KV stores)
  • Reduces origin load: Database/API only called once per cache period
  • Does NOT persist across deployments (unlike unstable_cache which does)
Note: connection() is the replacement for unstable_noStore(). Use it to explicitly opt into dynamic rendering.

Advanced: "use cache: private"

Caching Personalized User Data

"use cache: private" enables caching of user-specific data that depends on cookies() or headers().

Use Case: Cache personalized content per-user to improve performance without sharing data between users.
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { cacheLife, cacheTag } from 'next/cache'

async function Recommendations({ productId }: { productId: string }) {
  const recs = await getRecommendations(productId)
  
  return (
    <div>
      <h3>Recommended for You</h3>
      <RecommendationsList items={recs} />
    </div>
  )
}

async function getRecommendations(productId: string) {
  "use cache: private"
  cacheTag(`recommendations-${productId}`)
  cacheLife({ stale: 60 }) // Minimum 30s for prefetch
  
  // Access cookies for personalization
  const sessionId = (await cookies()).get('session-id')?.value
  
  // Per-user cache - never shared between users
  return getPersonalizedRecommendations(productId, sessionId)
}

export default function ProductPage({ params }) {
  return (
    <div>
      <h1>Product Details</h1>
      <Suspense fallback={<div>Loading recommendations...</div>}>
        <Recommendations productId={params.id} />
      </Suspense>
    </div>
  )
}

Key characteristics:

  • Can use runtime APIs: Access cookies(), headers(), and searchParams
  • Per-user isolation: Each user has their own cache - never shared
  • Client-side storage: Cached in the browser, not server
  • Enables prefetching: Supports runtime prefetching of personalized content

Cache Shell Requirements

generateStaticParams & Verification

For dynamic routes to generate a static shell with cached content, you must export generateStaticParams with at least one param. Without it, Next.js has no shell to cache into.

Common Pitfall: Without generateStaticParams, dynamic pages may fail because there's no Suspense boundary when reading params. Add a loading.tsx file or wrap the body in a Suspense boundary as a workaround.
// app/blog/[slug]/page.tsx
import { cacheLife } from 'next/cache'

export async function generateStaticParams() {
  const posts = await getPopularPosts()

  // Must return at least one param for the static shell
  return posts.map(post => ({ slug: post.slug }))
}

export default async function BlogPost({ params }) {
  "use cache"
  cacheLife("days")

  const { slug } = await params
  const post = await getPost(slug)

  return <article>{post.content}</article>
}

Verifying static behavior:

  • Static pages sometimes show as ppr in the build log — this can be misleading
  • The most reliable check: the x-nextjs-cache: HIT response header on the document request confirms the page is served from cache
  • Use browser DevTools Network tab → check response headers on the document request
Best Practice: Always verify your caching strategy works as expected by checking the x-nextjs-cache header in production, not just the build output.
1 / 13
Slide 1 of 13