Introduction
Next.js caching has evolved dramatically from simple static generation to a sophisticated multi-layered system that combines Incremental Static Regeneration (ISR), Partial Prerendering (PPR), and the newest Cache Components feature. Understanding how these three strategies work together — and when to use each one — is essential for building performant, scalable web applications in 2025.
Cache Components, introduced in Next.js 15, allow you to cache individual components rather than entire pages. Combined with PPR's ability to serve a static shell while streaming dynamic content, and ISR's time-based revalidation, you now have granular control over every layer of your application's caching strategy. This guide dives deep into how all three work under the hood and how to combine them effectively.
Understanding Caching Layers: Core Concepts
The Three Caching Strategies
Next.js provides three complementary caching mechanisms:
| Strategy | Granularity | Revalidation | Use Case |
|---|---|---|---|
| ISR | Page/route level | Time-based or on-demand | Content that changes periodically |
| PPR | Component level within a page | Static shell + dynamic streams | Pages with mixed static/dynamic content |
| Cache Components | Individual component level | CacheLife policies | Reusable components with predictable caching |
How ISR Works Under the Hood
ISR pre-renders pages at build time and serves them from a CDN cache. When the revalidation period expires, the next request triggers a background regeneration while still serving the stale page:
// app/products/[id]/page.tsx
export const revalidate = 3600; // Revalidate every hour
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return <ProductView product={product} />;
}The flow is:
- Build time: Page is pre-rendered and cached
- First request after revalidate period: Serve stale page, trigger background rebuild
- Subsequent requests: Serve the newly rebuilt page
- All requests within the revalidation window: Serve cached version instantly
How PPR (Partial Prerendering) Works
PPR splits a page into a static shell and dynamic holes. The static shell is served instantly from the CDN, while dynamic components stream in via React Suspense:
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
{/* Static shell — served from CDN */}
<h1>Dashboard</h1>
<nav>
<a href="/dashboard/analytics">Analytics</a>
<a href="/dashboard/orders">Orders</a>
</nav>
{/* Dynamic holes — streamed from server */}
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsPanel />
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders />
</Suspense>
</div>
);
}
async function AnalyticsPanel() {
const data = await getAnalytics(); // Dynamic, not cached
return <AnalyticsChart data={data} />;
}
async function RecentOrders() {
const orders = await getOrders(); // Dynamic, not cached
return <OrdersTable orders={orders} />;
}The static shell (header, nav, layout) is prerendered and cached. The AnalyticsPanel and RecentOrders components stream independently when requested.
How Cache Components Work
Cache Components let you cache individual components with explicit policies using CacheLife and CacheTag:
// components/ProductCard.tsx
import { unstable_cacheLife as cacheLife } from "next/cache";
export async function ProductCard({ id }: { id: string }) {
"use cache";
cacheLife("hours");
const product = await getProduct(id);
return (
<div className="border rounded-lg p-4">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
);
}The "use cache" directive marks the component as cacheable. cacheLife defines how long it stays cached. This is independent of ISR or PPR — it's a component-level cache that persists across requests.
Architecture and Design Patterns
Pattern 1: PPR Shell with ISR Content
Combine PPR's static shell with ISR-cached content slots:
// app/blog/[slug]/page.tsx
import { Suspense } from "react";
export const revalidate = 3600; // ISR for the page
export default async function BlogPost({ params }: { params: { slug: string } }) {
// This runs at build time or during ISR revalidation
const post = await getPost(params.slug);
return (
<article>
{/* Static content from ISR */}
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
{/* Dynamic content that streams via PPR */}
<Suspense fallback={<CommentsSkeleton />}>
<CommentsSection postId={post.id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedPosts postId={post.id} />
</Suspense>
</article>
);
}
async function CommentsSection({ postId }: { postId: string }) {
const comments = await getComments(postId); // Always fresh
return (
<section>
<h2>Comments ({comments.length})</h2>
{comments.map(c => <Comment key={c.id} comment={c} />)}
</section>
);
}Pattern 2: Cache Components for Shared UI
Use Cache Components for UI elements that appear across multiple pages:
// components/CategoryNav.tsx
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from "next/cache";
export async function CategoryNav() {
"use cache";
cacheLife("days");
cacheTag("categories");
const categories = await getCategories();
return (
<nav className="flex gap-4 overflow-x-auto py-2">
{categories.map(cat => (
<a
key={cat.id}
href={`/categories/${cat.slug}`}
className="px-4 py-2 bg-gray-100 rounded-full whitespace-nowrap hover:bg-gray-200"
>
{cat.name}
</a>
))}
</nav>
);
}This component is cached once and shared across all pages that render it. When categories change, purge by tag:
// app/api/categories/route.ts
import { revalidateTag } from "next/cache";
export async function POST() {
// After updating categories in the database
await revalidateTag("categories");
return Response.json({ revalidated: true });
}Pattern 3: Layered Caching Strategy
The most effective caching combines all three strategies at different layers:
// app/products/page.tsx
import { Suspense } from "react";
import { unstable_cacheLife as cacheLife } from "next/cache";
// ISR: Revalidate the page shell every 60 seconds
export const revalidate = 60;
export default function ProductsPage() {
return (
<div>
{/* Cache Component: Category nav cached for days */}
<CategoryNav />
{/* ISR: Product grid cached for 60 seconds */}
<ProductGrid />
{/* PPR: User-specific recommendations always dynamic */}
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</div>
);
}
async function ProductGrid() {
"use cache";
cacheLife("minutes");
const products = await getProducts();
return (
<div className="grid grid-cols-4 gap-4">
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}Step-by-Step Implementation
Step 1: Enable PPR in Next.js Config
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
ppr: true,
},
};
export default nextConfig;Step 2: Define CacheLife Policies
Create reusable cache policies for your application:
// lib/cache-policies.ts
import { unstable_cacheLife as cacheLife } from "next/cache";
// Call these in components to apply consistent caching
export function applyStaticCacheLife() {
cacheLife({
stale: 3600, // Serve stale for 1 hour
revalidate: 900, // Revalidate every 15 minutes
expire: 86400, // Hard expire after 24 hours
});
}
export function applyDynamicCacheLife() {
cacheLife({
stale: 0, // Never serve stale
revalidate: 30, // Revalidate every 30 seconds
expire: 300, // Hard expire after 5 minutes
});
}
export function applyStaticAssetCacheLife() {
cacheLife({
stale: 86400, // Serve stale for 24 hours
revalidate: 604800, // Revalidate weekly
expire: 2592000, // Hard expire after 30 days
});
}Step 3: Implement Cache Tags for Targeted Purging
// components/BlogPost.tsx
import { unstable_cacheTag as cacheTag } from "next/cache";
export async function BlogPost({ slug }: { slug: string }) {
"use cache";
cacheTag(`blog-${slug}`);
cacheTag("blog-all");
const post = await getPostBySlug(slug);
return (
<article className="prose max-w-none">
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}Purge specific blog posts or all blog posts:
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
export async function POST(request: Request) {
const { slug, all } = await request.json();
if (all) {
revalidateTag("blog-all");
} else if (slug) {
revalidateTag(`blog-${slug}`);
}
return Response.json({ revalidated: true });
}Step 4: Combine PPR with Cache Components
// app/checkout/page.tsx
import { Suspense } from "react";
export default function CheckoutPage() {
return (
<div className="max-w-4xl mx-auto p-8">
{/* Static shell */}
<h1 className="text-3xl font-bold mb-8">Checkout</h1>
<div className="grid grid-cols-3 gap-8">
<div className="col-span-2">
{/* Dynamic: User's cart */}
<Suspense fallback={<CartSkeleton />}>
<CartItems />
</Suspense>
</div>
<aside>
{/* Cached: Shipping rates (same for all users) */}
<ShippingRates />
{/* Dynamic: User-specific discount */}
<Suspense fallback={<DiscountSkeleton />}>
<DiscountCode />
</Suspense>
</aside>
</div>
</div>
);
}
async function ShippingRates() {
"use cache";
const rates = await getShippingRates();
return (
<div className="bg-gray-50 rounded-lg p-4">
<h3 className="font-semibold mb-3">Shipping Options</h3>
{rates.map(rate => (
<div key={rate.id} className="flex justify-between py-2">
<span>{rate.name}</span>
<span>${rate.price}</span>
</div>
))}
</div>
);
}Real-World Use Cases
Use Case 1: News Website with Breaking Content
A news site uses ISR with a 60-second revalidation period for article pages, but uses Cache Components for the navigation and category sidebar (cached for hours). Breaking news updates trigger on-demand revalidation via revalidateTag. PPR handles the comment section and related articles as dynamic streams, so the article content appears instantly while comments load progressively.
Use Case 2: E-Commerce Product Catalog
An e-commerce platform caches product listing pages with ISR (revalidating every 5 minutes), caches the product detail component with Cache Life policies (1 hour stale, 15 minutes revalidate), and uses PPR for user-specific elements like cart count, recommendations, and pricing with personalized discounts. The static shell loads in under 200ms while dynamic personalization streams in.
Use Case 3: SaaS Dashboard with Mixed Data
A SaaS analytics dashboard renders the sidebar navigation and header as Cache Components (rarely changes), uses PPR to stream real-time metrics and charts, and applies ISR to the report generation page that shows aggregated data updated every 15 minutes. This gives users instant access to the dashboard shell while data loads progressively.
Best Practices for Production
-
Start with ISR for most pages: ISR is the simplest caching strategy to implement and provides the best balance of performance and freshness for most content.
-
Use PPR for pages with mixed static/dynamic content: If a page has a stable layout but dynamic data slots, PPR gives you the best of both worlds — instant static shell with streamed dynamic content.
-
Apply Cache Components to shared UI: Components like navigation, footers, and category lists that appear on multiple pages benefit most from component-level caching.
-
Use CacheTags for granular invalidation: Tag each cacheable component with specific tags so you can invalidate precisely without affecting unrelated components.
-
Define CacheLife policies consistently: Create a centralized set of cache policies (e.g., "minutes", "hours", "days") and apply them consistently across your application.
-
Monitor cache hit rates: Use Next.js analytics or custom logging to track cache hit rates for each layer. Low hit rates indicate you need to adjust revalidation periods.
-
Test cache invalidation paths: Write integration tests that verify
revalidateTagandrevalidatePathcorrectly invalidate cached content without breaking other cached components. -
Use
unstable_cachefor non-component caching: For data fetching outside of components (e.g., in utility functions), useunstable_cachewith explicit key generation and revalidation options.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Caching user-specific data in Cache Components | Users see other users' data | Never use "use cache" for user-specific content; use PPR dynamic streams instead |
Forgetting to define defaultCacheLife | Components use aggressive defaults | Always explicitly set cacheLife in cached components |
| ISR revalidation race conditions | Stale content served during rebuild | Use on-demand revalidation (revalidateTag) for critical updates |
| PPR not working in development | Confusion during development | PPR requires production build (next build && next start) to see full effect |
| Over-caching dynamic content | Users see outdated information | Use shorter revalidation periods for frequently changing data |
| Cache tag naming collisions | Wrong components invalidated | Use hierarchical naming: blog-${slug}, product-${id}, category-${slug} |
Performance Optimization
// Optimal layered caching example
import { Suspense } from "react";
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from "next/cache";
// Page-level ISR
export const revalidate = 60;
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
{/* Layer 1: Cache Component (cached for hours) */}
<Header />
{/* Layer 2: ISR-cached product content */}
<ProductDetails id={params.id} />
{/* Layer 3: PPR dynamic stream (always fresh) */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} />
</Suspense>
</div>
);
}
async function ProductDetails({ id }: { id: string }) {
"use cache";
cacheLife("hours");
cacheTag(`product-${id}`);
const product = await getProduct(id);
const images = await getProductImages(id);
return (
<div className="grid grid-cols-2 gap-8">
<ImageGallery images={images} />
<div>
<h1>{product.name}</h1>
<p className="text-2xl font-bold">${product.price}</p>
<p>{product.description}</p>
<AddToCartButton productId={id} />
</div>
</div>
);
}This architecture ensures the page loads in under 100ms for cached users, with reviews streaming in within 500ms.
Comparison with Alternatives
| Feature | ISR | PPR | Cache Components | CDN Caching |
|---|---|---|---|---|
| Granularity | Page | Page shell + holes | Component | URL path |
| Revalidation | Time/on-demand | Per-stream | CacheLife policy | TTL headers |
| User-specific | No | Yes (dynamic streams) | No | No |
| Setup complexity | Low | Medium | Medium | High |
| Best for | Blog posts, docs | Dashboards, feeds | Shared UI, nav | Static assets |
Advanced Patterns
Custom CacheLife Definitions
import { unstable_cacheLife as cacheLife } from "next/cache";
// Define in a shared module
export const cachePolicies = {
// Content that changes rarely (about pages, legal)
static: { stale: 86400, revalidate: 3600, expire: 604800 },
// Content that changes periodically (product pages, blog posts)
hourly: { stale: 3600, revalidate: 600, expire: 86400 },
// Content that changes frequently (prices, inventory)
realtime: { stale: 0, revalidate: 30, expire: 300 },
// Content that changes very rarely (navigation, footer)
permanent: { stale: 604800, revalidate: 86400, expire: 2592000 },
};Programmatic Cache Invalidation
// lib/cache-manager.ts
import { revalidateTag, revalidatePath } from "next/cache";
export class CacheManager {
static async invalidateProduct(id: string) {
revalidateTag(`product-${id}`);
revalidateTag("product-list");
revalidatePath("/products");
}
static async invalidateBlog(slug: string) {
revalidateTag(`blog-${slug}`);
revalidateTag("blog-all");
revalidatePath("/blog");
}
static async invalidateAll() {
// Nuclear option — use sparingly
revalidatePath("/", "layout");
}
}Testing Strategies
import { render, screen, waitFor } from "@testing-library/react";
import ProductPage from "@/app/products/[id]/page";
describe("Cache Components and PPR", () => {
it("renders cached product details immediately", async () => {
const Page = await ProductPage({ params: { id: "1" } });
render(Page);
// Cached content appears immediately
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("$99.99")).toBeInTheDocument();
});
it("shows skeleton for dynamic content", () => {
render(<ProductPage params={{ id: "1" }} />);
// Dynamic content shows loading state
expect(screen.getByTestId("reviews-skeleton")).toBeInTheDocument();
});
it("invalidates cache by tag", async () => {
const response = await fetch("http://localhost:3000/api/revalidate", {
method: "POST",
body: JSON.stringify({ slug: "test-product" }),
});
expect(response.status).toBe(200);
const data = await response.json();
expect(data.revalidated).toBe(true);
});
});Future Outlook
The Next.js team is actively developing Cache Components as a stable feature (currently behind the unstable_ prefix). Future improvements include more granular cache invalidation APIs, automatic cache warming based on traffic patterns, and integration with edge caching networks for global distribution. The React team's work on server component streaming will further improve PPR's performance, reducing the time for dynamic content to appear after the static shell.
Partial Prerendering is expected to become the default rendering mode in future Next.js versions, with ISR and Cache Components serving as complementary strategies for specific use cases rather than alternatives.
Conclusion
The combination of ISR, PPR, and Cache Components gives Next.js developers unprecedented control over application caching. Each strategy addresses a different layer of the caching problem — ISR for page-level revalidation, PPR for static shells with dynamic holes, and Cache Components for reusable UI elements.
Key takeaways:
- Use ISR for content-heavy pages that change periodically but don't need real-time freshness.
- Use PPR for pages with mixed static and dynamic content — the static shell loads instantly while dynamic data streams in.
- Use Cache Components for shared UI elements like navigation, footers, and category lists that rarely change.
- Combine all three strategies for optimal performance — static shell via PPR, cached components for shared UI, and ISR for content that revalidates on a schedule.
- Use CacheTags for precise invalidation so you can update specific content without affecting the rest of your cache.
Start by enabling PPR in your Next.js config, then identify which components benefit from Cache Components, and finally apply ISR to content-heavy pages. The layered approach gives you the best balance of performance, freshness, and developer experience.
For more details, see the Next.js Caching documentation and the Partial Prerendering guide.