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.
What's new in Next.js 16:
<Suspense> or "use cache". Otherwise, you'll get a build error.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.
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":
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.
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:
| Profile | Use Case | Stale | Revalidate | Expire |
|---|---|---|---|---|
default | Standard content | 5 min | 15 min | 1 year |
seconds | Real-time data | 30 sec | 1 sec | 1 min |
minutes | News feeds, social | 5 min | 1 min | 1 hour |
hours | Product pages | 5 min | 1 hour | 1 day |
days | Blog posts, articles | 5 min | 1 day | 1 week |
weeks | Newsletters, podcasts | 5 min | 1 week | 30 days |
max | Archived, legal docs | 5 min | 30 days | 1 year |
cacheTag() lets you tag cached content with identifiers. When content updates, you can invalidate specific tags instead of clearing all caches.
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:
blog-post-123 for individual itemscategory-electronics for groupsproducts or users for entity typesblog-post-123, blog-posts, content for flexible invalidationNext.js 16 provides two functions for cache invalidation, each optimized for different scenarios.
updateTag is for immediate "read-your-own-writes" in Server Actions, while revalidateTag is for background revalidation in Route Handlers and webhooks.| Feature | updateTag | revalidateTag |
|---|---|---|
| Where to use | Server Actions only | Server Actions & Route Handlers |
| Behavior | Next request waits for fresh data | Serves stale while revalidating in background |
| Use case | User sees their own changes immediately | Background 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}`)
}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.
Step 1: Enable Cache Components
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigQuick Reference - What Changed:
| Next.js 15 (Old) | Next.js 16 (New) |
|---|---|
experimental.ppr | cacheComponents: true |
dynamic = 'force-dynamic' | Remove (default behavior in Next.js 16) |
dynamic = 'force-static' | "use cache" + cacheLife('max') |
revalidate = N | cacheLife({ 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
<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".
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.
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:
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.
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:
"use cache: private" when you need to access runtime APIs directly inside a cached function (covered in advanced slides).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 nextConfigWhen to create custom profiles:
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:
Regular "use cache" doesn't work after calling connection(). Use "use cache: remote" to cache shared data in these dynamic contexts.
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:
connection() where regular cache failsunstable_cache which does)connection() is the replacement for unstable_noStore(). Use it to explicitly opt into dynamic rendering."use cache: private" enables caching of user-specific data that depends on cookies() or headers().
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:
cookies(), headers(), and searchParamsFor 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.
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:
ppr in the build log — this can be misleadingx-nextjs-cache: HIT response header on the document request confirms the page is served from cachex-nextjs-cache header in production, not just the build output.