TL;DR
- Next.js 16 replaces PPR with Cache Components — explicit
"use cache"directives. - You decide what to cache. Dynamic code runs at request time by default.
- Three new APIs:
updateTag()(immediate),revalidateTag()(stale-while-revalidate),refresh()(uncached data). - Caching is now opt-in and explicit — no more guessing what Next.js cached.
Step 1: The New Caching Model
Next.js's caching story was the #1 community complaint in v14-15 — implicit, confusing, and nearly impossible to debug. The team rebuilt it from scratch in v16 with an explicit model: you mark what to cache with "use cache". This approach was inspired by React Server Components' "use client" directive: a simple pragma that makes intent clear. No more guessing whether your page is static or dynamic, no more export const revalidate magic — you explicitly opt into caching where you want it.
Before (Next.js 15 — PPR)
PPR (Partial Prerendering) was global and implicit. You configured it in next.config.js and it tried to figure out what was static vs dynamic.
After (Next.js 16 — Cache Components)
You mark specific components/functions with "use cache":
// app/posts/page.tsx
import { Suspense } from "react";
// This component is cached — output served from cache until invalidated
async function BlogPosts() {
"use cache";
const posts = await db.posts.findMany();
return <PostList posts={posts} />;
}
// This page renders dynamically by default
export default function PostsPage() {
return (
<Suspense fallback={<PostsSkeleton />}>
<BlogPosts />
</Suspense>
);
}
Enable in Config
// next.config.ts
const nextConfig = {
cacheComponents: true,
};
export default nextConfig;
Step 2: Where You Can Use "use cache"
The "use cache" directive was designed to be granular — you can cache an entire page, a single component, or just a data-fetching function. This granularity was a deliberate improvement over Next.js 14-15 where caching was all-or-nothing per route segment. Now you can have a cached navigation bar alongside a dynamic user feed on the same page, with each piece having its own cache lifetime and invalidation rules. This composable caching model is what frameworks like Rails and Laravel took decades to develop.
The directive works in multiple places:
On Page-Level
// app/about/page.tsx — entire page is cached
export default async function AboutPage() {
"use cache";
const content = await cms.getPage("about");
return <div dangerouslySetInnerHTML={{ __html: content.html }} />;
}
On Individual Components
// components/user-stats.tsx
async function UserStats({ userId }: { userId: string }) {
"use cache";
const stats = await db.stats.get(userId);
return (
<div>
<p>Posts: {stats.postCount}</p>
<p>Followers: {stats.followers}</p>
</div>
);
}
On Functions (Data Layer)
// lib/data.ts
export async function getPopularPosts() {
"use cache";
return db.posts.findMany({
orderBy: { views: "desc" },
take: 10,
});
}
On Layouts
// app/layout.tsx
export default async function RootLayout({ children }) {
"use cache";
const navigation = await cms.getNavigation();
return (
<html>
<body>
<Nav items={navigation} />
{children}
</body>
</html>
);
}
Step 3: Cache Tags & Invalidation
Cache invalidation is famously one of the two hardest problems in computer science (alongside naming things). Next.js 16's tag-based invalidation was built because time-based TTLs are too blunt — if a blog post is updated, you want to invalidate that specific post's cache immediately, not wait for a timer. cacheTag() lets you label cached data with semantic tags, and revalidateTag() invalidates all cache entries with that tag. This pattern (popularized by CDNs like Cloudflare) gives you the performance of caching with the freshness of dynamic rendering.
Tagging Cached Data
import { cacheTag } from "next/cache";
async function UserProfile({ userId }: { userId: string }) {
"use cache";
cacheTag(`user-${userId}`);
const user = await db.users.findUnique({ where: { id: userId } });
return <ProfileCard user={user} />;
}
updateTag() — Immediate Refresh
Use when the user must see their change immediately (read-your-writes):
"use server";
import { updateTag } from "next/cache";
export async function updateProfile(userId: string, data: ProfileData) {
await db.users.update(userId, data);
updateTag(`user-${userId}`);
// Next request gets fresh data — no stale response
}
⚠️ Only works in Server Actions — not Route Handlers.
revalidateTag() — Stale-While-Revalidate
Use when brief staleness is acceptable (blog posts, product listings):
"use server";
import { revalidateTag } from "next/cache";
export async function publishPost(postId: string) {
await db.posts.publish(postId);
// User sees stale content immediately, fresh content loads in background
revalidateTag("blog-posts", "max");
// Or with custom timing
revalidateTag("homepage", { expire: 3600 }); // 1 hour
}
Built-in profiles: "max", "hours", "days".
refresh() — Update Uncached Data
Use for real-time indicators that shouldn't be cached:
"use server";
import { refresh } from "next/cache";
export async function markNotificationRead(id: string) {
await db.notifications.markRead(id);
refresh(); // Updates notification count without touching page cache
}
When to Use Each
| API | Use Case | Behavior |
|---|---|---|
updateTag() |
User edits own data | Blocks until fresh data arrives |
revalidateTag() |
Content updates | Shows stale, refreshes in background |
refresh() |
Real-time indicators | Updates uncached data only |
Step 4: Cache Boundaries & Composition
Cache boundaries solve the problem of mixed-content pages: a page might have a cached sidebar (changes weekly), a semi-cached product list (changes hourly), and a fully dynamic shopping cart (changes per user). Without composable cache boundaries, you'd have to choose one caching strategy for the entire page. React's Suspense boundaries naturally map to cache boundaries in Next.js 16, letting you stream dynamic content while serving cached shells instantly.
Mixing Cached and Dynamic
// Layout is cached (navigation rarely changes)
export default async function Layout({ children }) {
"use cache";
cacheTag("navigation");
const nav = await getNavigation();
return (
<div>
<Sidebar items={nav} />
{/* children can be dynamic — cache boundary doesn't propagate down */}
{children}
</div>
);
}
// Page is dynamic (personalized content)
export default async function DashboardPage() {
const user = await getCurrentUser(); // No "use cache" — runs every request
return <Dashboard user={user} />;
}
Nested Cache Boundaries
export default async function BlogPage() {
return (
<div>
{/* Cached independently with different tags */}
<Suspense fallback={<Skeleton />}>
<PopularPosts /> {/* "use cache" + tag: "popular" */}
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentPosts /> {/* "use cache" + tag: "recent" */}
</Suspense>
{/* Always dynamic */}
<UserGreeting />
</div>
);
}
Step 5: Migration from PPR (Next.js 15 → 16)
The migration from PPR to "use cache" was designed to be incremental — the Next.js team provided codemods that automatically transform most patterns. PPR was deprecated because it tried to be too magic: automatically detecting static vs dynamic parts of a page led to unpredictable behavior and was impossible to debug when things went wrong. The explicit "use cache" model trades a few extra lines of code for complete clarity about what's cached and why.
Before (PPR)
// next.config.js (Next.js 15)
module.exports = {
experimental: {
ppr: true,
},
};
// Component — PPR figured out static/dynamic automatically
export default async function Page() {
const staticData = await getStaticContent(); // Cached (detected)
const dynamicData = await getCurrentUser(); // Dynamic (detected)
return <div>{staticData}{dynamicData}</div>;
}
After (Cache Components)
// next.config.ts (Next.js 16)
const nextConfig = {
cacheComponents: true,
};
// Explicit about what's cached
async function StaticContent() {
"use cache";
cacheTag("content");
return await getStaticContent();
}
export default async function Page() {
const dynamicData = await getCurrentUser(); // Dynamic by default
return (
<div>
<Suspense fallback={<Skeleton />}>
<StaticContent />
</Suspense>
{dynamicData}
</div>
);
}
Interview Questions
-
What replaced PPR in Next.js 16?
- Cache Components with
"use cache"directives. They give explicit, code-level control over what gets cached.
- Cache Components with
-
What's the difference between
updateTagandrevalidateTag?updateTagblocks until fresh data is ready (immediate consistency).revalidateTagserves stale data and refreshes in background (eventual consistency).
-
Does
"use cache"propagate to child components?- No. Each
"use cache"creates an independent cache boundary. Child components without the directive run dynamically.
- No. Each
-
How do you cache differently per-user?
- Cache tags can include user-specific identifiers:
cacheTag(\user-${userId}`)`. Then invalidate per-user when their data changes.
- Cache tags can include user-specific identifiers: