Introduction
Traditional web caching operates at the page level — you cache an entire HTML response and serve it until it expires. This approach works for simple pages but falls apart when different components on the same page have vastly different freshness requirements. A product page might have a static header that rarely changes, a price that updates every few minutes, and a stock indicator that needs to be real-time.
Next.js Cache Components solve this by bringing caching to the individual component level. Using the "use cache" directive, CacheLife policies, and CacheTag for targeted invalidation, you can define exactly how long each component's output should be cached, when it should be revalidated, and how to purge it when data changes.
This guide covers everything you need to implement fine-grained caching control in your Next.js application — from basic setup to advanced patterns combining Cache Components with ISR and Partial Prerendering.
Understanding Cache Components: Core Concepts
The "use cache" Directive
The "use cache" directive tells Next.js that a component's output can be cached. It's placed at the top of an async server component:
// components/ProductCard.tsx
export async function ProductCard({ id }: { id: string }) {
"use cache";
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 className="text-lg font-bold">${product.price}</p>
</div>
);
}When this component is rendered, its output is cached. Subsequent renders with the same id prop return the cached version without re-executing the data fetching or rendering logic.
CacheLife: Defining Cache Duration
CacheLife controls how long a cached component remains valid. It accepts three values:
import { unstable_cacheLife as cacheLife } from "next/cache";
export async function ProductCard({ id }: { id: string }) {
"use cache";
cacheLife({
stale: 3600, // Serve stale content for up to 1 hour
revalidate: 300, // Revalidate every 5 minutes
expire: 86400, // Hard expire after 24 hours
});
const product = await getProduct(id);
return <ProductView product={product} />;
}| Parameter | Purpose | Default |
|---|---|---|
stale | How long to serve stale content without revalidation | 300s (5 min) |
revalidate | How often to check for fresh content | 300s (5 min) |
expire | Maximum time to serve cached content before hard refresh | 86400s (24 hr) |
The relationship: During the stale window, cached content is served instantly with no revalidation. Between stale and revalidate, cached content is served while a background revalidation triggers. After expire, the cache is forcefully invalidated.
Pre-built CacheLife Profiles
Next.js provides convenience functions for common caching patterns:
import { unstable_cacheLife as cacheLife } from "next/cache";
// For content that changes rarely (about pages, documentation)
cacheLife("days"); // stale: 1d, revalidate: 1h, expire: 1w
// For content that changes periodically (product listings)
cacheLife("hours"); // stale: 1h, revalidate: 5m, expire: 1d
// For content that changes frequently (prices, stock)
cacheLife("minutes"); // stale: 5m, revalidate: 1m, expire: 1h
// For content that changes very rarely (navigation, footer)
cacheLife("weeks"); // stale: 1w, revalidate: 1d, expire: 30dCacheTag: Targeted Invalidation
CacheTag lets you group cached components and invalidate them together:
import { unstable_cacheTag as cacheTag } from "next/cache";
export async function ProductCard({ id }: { id: string }) {
"use cache";
cacheTag(`product-${id}`);
cacheTag("product-list");
const product = await getProduct(id);
return <ProductView product={product} />;
}Now you can invalidate a specific product or all product cards:
import { revalidateTag } from "next/cache";
// Invalidate one product
revalidateTag("product-123");
// Invalidate all product cards
revalidateTag("product-list");Architecture and Design Patterns
Pattern 1: Component-Level Cache Hierarchy
Structure your caching in layers, from most to least frequently cached:
// Layer 1: Rarely changes (cached for weeks)
export async function SiteHeader() {
"use cache";
cacheLife("weeks");
cacheTag("site-header");
const nav = await getNavigation();
return (
<header className="bg-white shadow">
<nav className="max-w-7xl mx-auto px-4 py-3 flex gap-6">
{nav.map(item => (
<a key={item.href} href={item.href} className="hover:text-blue-600">
{item.label}
</a>
))}
</nav>
</header>
);
}
// Layer 2: Changes periodically (cached for hours)
export async function CategorySidebar() {
"use cache";
cacheLife("hours");
cacheTag("categories");
const categories = await getCategories();
return (
<aside className="w-64">
<h2 className="font-semibold mb-4">Categories</h2>
{categories.map(cat => (
<a key={cat.id} href={`/c/${cat.slug}`} className="block py-1">
{cat.name} ({cat.count})
</a>
))}
</aside>
);
}
// Layer 3: Changes frequently (cached for minutes)
export async function PriceDisplay({ productId }: { productId: string }) {
"use cache";
cacheLife("minutes");
cacheTag(`price-${productId}`);
const price = await getPrice(productId);
return (
<div className="text-2xl font-bold text-green-600">
${price.current}
{price.original > price.current && (
<span className="ml-2 text-sm text-gray-500 line-through">
${price.original}
</span>
)}
</div>
);
}Pattern 2: Webhook-Driven Cache Invalidation
Connect your CMS or database webhooks to cache invalidation:
// app/api/webhook/cms/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const payload = await request.json();
const signature = request.headers.get("x-webhook-signature");
if (!verifySignature(payload, signature)) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
switch (payload.type) {
case "product.updated":
revalidateTag(`product-${payload.data.id}`);
revalidateTag("product-list");
break;
case "category.updated":
revalidateTag("categories");
revalidateTag("product-list"); // Products show category names
break;
case "site.updated":
revalidateTag("site-header");
revalidateTag("site-footer");
break;
default:
return Response.json({ error: "Unknown event type" }, { status: 400 });
}
return Response.json({ revalidated: true, type: payload.type });
}Pattern 3: Cache-Aware Data Fetching
Create data fetching utilities that integrate with the cache system:
// lib/cached-fetch.ts
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from "next/cache";
type CachePolicy = "static" | "dynamic" | "realtime";
const policies: Record<CachePolicy, { life: Parameters<typeof cacheLife>[0]; tags: string[] }> = {
static: { life: { stale: 86400, revalidate: 3600, expire: 604800 }, tags: [] },
dynamic: { life: { stale: 300, revalidate: 60, expire: 3600 }, tags: [] },
realtime: { life: { stale: 0, revalidate: 10, expire: 60 }, tags: [] },
};
export function cachedFetch<T>(
url: string,
options: {
policy: CachePolicy;
tags?: string[];
}
): Promise<T> {
"use cache";
cacheLife(policies[options.policy].life);
if (options.tags) {
options.tags.forEach(tag => cacheTag(tag));
}
return fetch(url, { next: { tags: options.tags } }).then(r => r.json());
}Step-by-Step Implementation
Step 1: Identify Cacheable Components
Audit your application and categorize components by freshness requirements:
// scripts/cache-audit.ts
// Components to cache with their policies
const cacheAudit = {
"SiteHeader": "weeks", // Navigation rarely changes
"Footer": "weeks", // Footer content is static
"CategoryNav": "days", // Categories update occasionally
"ProductCard": "hours", // Product info updates periodically
"PriceDisplay": "minutes", // Prices change frequently
"StockIndicator": "realtime", // Stock must be accurate
"UserAvatar": "hours", // Profile images update rarely
"BlogPost": "days", // Blog content is stable
};Step 2: Implement Cache Components
// components/ProductCard.tsx
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from "next/cache";
interface ProductCardProps {
id: string;
showPrice?: boolean;
}
export async function ProductCard({ id, showPrice = true }: ProductCardProps) {
"use cache";
cacheLife("hours");
cacheTag(`product-${id}`, "product-list");
const product = await getProduct(id);
return (
<div className="border rounded-lg overflow-hidden hover:shadow-lg transition-shadow">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover"
/>
<div className="p-4">
<h3 className="font-semibold text-lg">{product.name}</h3>
<p className="text-gray-600 text-sm mt-1">{product.shortDescription}</p>
{showPrice && (
<div className="mt-3 flex items-center justify-between">
<span className="text-xl font-bold text-green-600">
${product.price.toFixed(2)}
</span>
{product.onSale && (
<span className="bg-red-100 text-red-800 text-xs px-2 py-1 rounded">
Sale
</span>
)}
</div>
)}
</div>
</div>
);
}Step 3: Set Up Cache Invalidation Webhooks
// app/api/revalidate/route.ts
import { revalidateTag } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
// Verify API key
const apiKey = request.headers.get("x-api-key");
if (apiKey !== process.env.REVALIDATION_API_KEY) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { tags, paths } = body;
if (tags && Array.isArray(tags)) {
tags.forEach((tag: string) => revalidateTag(tag));
}
return Response.json({
revalidated: true,
tags: tags || [],
timestamp: Date.now(),
});
}Step 4: Combine with ISR and PPR
// app/products/page.tsx
import { Suspense } from "react";
import { ProductCard } from "@/components/ProductCard";
import { SiteHeader } from "@/components/SiteHeader";
export const revalidate = 300; // ISR: page-level cache for 5 minutes
export default function ProductsPage() {
return (
<div>
{/* Cache Component: Header cached for weeks */}
<SiteHeader />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">Products</h1>
{/* Cache Component: Product grid cached for hours */}
<ProductGrid />
{/* PPR: Dynamic personalization */}
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</main>
</div>
);
}
async function ProductGrid() {
const products = await getProducts();
return (
<div className="grid grid-cols-4 gap-6">
{products.map(product => (
<ProductCard key={product.id} id={product.id} />
))}
</div>
);
}Real-World Use Cases
Use Case 1: E-Commerce Product Listing
An online store with 10,000 products caches the product listing grid with cacheLife("hours") and individual product cards with cacheLife("hours"). When a product price changes, the webhook sends revalidateTag("product-123") to invalidate only that specific card. The rest of the grid remains cached. This reduces database queries by 95% compared to fetching all products on every request.
Use Case 2: Blog with CMS Integration
A blog platform caches article pages with cacheLife("days") and tags each with cacheTag("blog-${slug}"). When an editor updates a post in the CMS, a webhook triggers revalidateTag("blog-my-post-title"). The navigation and sidebar categories are cached with cacheLife("weeks") and invalidated only when categories change in the admin panel.
Use Case 3: SaaS Dashboard Components
A SaaS analytics dashboard caches the sidebar navigation (cacheLife("weeks")), chart configurations (cacheLife("days")), and metric cards (cacheLife("minutes")). Real-time data like user activity feeds uses PPR's dynamic streaming instead of caching. This gives users instant access to the dashboard layout while data streams in progressively.
Best Practices for Production
-
Never cache user-specific data with
"use cache": Cache Components are shared across all users. User-specific content (shopping carts, profile data) should use PPR's dynamic streams instead. -
Use hierarchical CacheTags: Structure tags as
type-${id}andtype-listso you can invalidate individual items or entire collections with a single call. -
Set explicit CacheLife for every cached component: Don't rely on defaults — define exactly how long each component should be cached based on its data freshness requirements.
-
Implement webhook-based invalidation: Connect your CMS, database, or API to trigger cache invalidation when data changes. Time-based revalidation alone leads to stale content.
-
Monitor cache hit rates: Log cache hits and misses to identify components that are being invalidated too frequently or cached for too long.
-
Test cache invalidation: Write integration tests that verify
revalidateTagcorrectly invalidates the right components without affecting unrelated cached content. -
Use
expireas a safety net: Even ifrevalidateis short, setexpireto a reasonable maximum to prevent serving very stale content if revalidation fails. -
Document your caching strategy: Create a caching policy document that lists every cacheable component, its CacheLife, its CacheTags, and its invalidation triggers.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Caching user-specific data | Users see other users' data | Use PPR dynamic streams for user content; never use "use cache" for personalized data |
| Missing CacheTags | Can't invalidate specific components | Always add relevant tags: product-${id}, blog-${slug} |
| Overly aggressive caching | Stale prices or stock shown to users | Use cacheLife("minutes") for frequently changing data |
| Cache stampede on invalidation | Server overwhelmed after revalidation | Use stale period to serve cached content while revalidating in background |
Forgetting expire | Cached content never refreshes if revalidation fails | Always set expire as a safety net |
| Invalidating too broadly | Performance hit from rebuilding unrelated components | Use specific tags, not broad ones like revalidatePath("/") |
Performance Optimization
// Optimized product card with selective caching
export async function ProductCard({ id }: { id: string }) {
"use cache";
cacheLife({
stale: 600, // Serve stale for 10 minutes
revalidate: 120, // Revalidate every 2 minutes
expire: 3600, // Hard expire after 1 hour
});
cacheTag(`product-${id}`, "product-list");
const product = await getProduct(id);
const isInStock = await checkStock(id); // Separate fetch, also cached
return (
<div className={`border rounded-lg p-4 ${!isInStock ? "opacity-50" : ""}`}>
<img src={product.image} alt={product.name} className="w-full h-48 object-cover rounded" />
<h3 className="font-semibold mt-2">{product.name}</h3>
<p className="text-lg font-bold">${product.price.toFixed(2)}</p>
{!isInStock && <p className="text-red-600 text-sm">Out of stock</p>}
</div>
);
}Comparison with Alternatives
| Approach | Granularity | Invalidation | Complexity | Best For |
|---|---|---|---|---|
| Cache Components | Component | Tag-based | Medium | Shared UI, reusable components |
| ISR | Page | Time/on-demand | Low | Content-heavy pages |
| PPR | Page sections | Always fresh | Medium | User-specific dynamic content |
| CDN caching | URL | TTL headers | High | Static assets, API responses |
| React Query | Client state | Manual/Auto | Low | Client-side data fetching |
Advanced Patterns
Cache Warming
Pre-populate the cache for high-traffic pages:
// lib/cache-warmer.ts
import { revalidateTag } from "next/cache";
export async function warmProductCache(productIds: string[]) {
// Fetch and cache the most popular products
await Promise.all(
productIds.map(async (id) => {
const response = await fetch(`http://localhost:3000/products/${id}`);
return response.text(); // Triggers caching
})
);
}Conditional Caching
Cache based on runtime conditions:
export async function ProductPrice({ id }: { id: string }) {
const product = await getProduct(id);
// Only cache if the product is published
if (product.status === "published") {
"use cache";
cacheLife("minutes");
cacheTag(`price-${id}`);
}
return <span className="text-xl font-bold">${product.price}</span>;
}Testing Strategies
import { render, screen, waitFor } from "@testing-library/react";
import { ProductCard } from "@/components/ProductCard";
describe("Cache Components", () => {
it("renders product card from cache", async () => {
const { container } = render(await ProductCard({ id: "1" }));
expect(screen.getByText("Test Product")).toBeInTheDocument();
expect(screen.getByText("$99.99")).toBeInTheDocument();
});
it("invalidates specific product via tag", async () => {
const response = await fetch("http://localhost:3000/api/revalidate", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": "test-key" },
body: JSON.stringify({ tags: ["product-1"] }),
});
expect(response.status).toBe(200);
});
it("does not affect other products when invalidating one", async () => {
// Invalidate product-1
await fetch("http://localhost:3000/api/revalidate", {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": "test-key" },
body: JSON.stringify({ tags: ["product-1"] }),
});
// Product-2 should still be cached
const start = Date.now();
render(await ProductCard({ id: "2" }));
const elapsed = Date.now() - start;
expect(elapsed).toBeLessThan(50); // Should be instant from cache
});
});Future Outlook
The Next.js team is working to stabilize the Cache Components API (currently prefixed with unstable_). Future improvements include visual cache debugging in DevTools, automatic cache warming based on traffic analysis, and integration with edge caching networks for global distribution. The ability to define cache policies at the component level represents a fundamental shift in how web applications manage data freshness, and it's expected to become the standard pattern for high-performance Next.js applications.
Conclusion
Cache Components bring granular, component-level caching control to Next.js. By combining "use cache", CacheLife, and CacheTag, you can cache exactly the right components for exactly the right duration and invalidate them precisely when data changes.
Key takeaways:
- Use
"use cache"to mark components as cacheable — their output is stored and reused across requests. - Define CacheLife policies explicitly for every cached component based on its data freshness requirements.
- Use CacheTags for targeted invalidation — invalidate specific items with
product-${id}or entire collections withproduct-list. - Connect webhooks to cache invalidation so your cache stays fresh automatically when data changes in your CMS or database.
- Never cache user-specific content — use PPR's dynamic streams for personalized data.
Start by auditing your components for caching opportunities, implement CacheTags with a consistent naming convention, and connect your data sources to cache invalidation webhooks. The performance gains are immediate and significant.
For more details, see the Next.js Cache Components documentation.