Next.js 16 Cache Components and Partial Pre-Rendering
Our product pages had a problem: 80% of the content was static (product info, images, descriptions) but 20% was dynamic (inventory, pricing, recommendations). In Next.js 15, we chose between static generation or dynamic rendering for the entire page. Next.js 16's Cache Components let us mix both—static shells with dynamic holes. Here's how it works.
The Evolution from PPR
Partial Pre-Rendering (PPR) was experimental in Next.js 15. It pre-rendered static content and streamed dynamic parts. Cache Components is the stable evolution of that concept.
// Next.js 15 with experimental PPR
module.exports = {
experimental: {
ppr: true,
},
};
// Next.js 16 with Cache Components
module.exports = {
cacheComponents: true,
};The configuration is simpler, but the underlying model is more powerful.
The Cache Model
Cache Components uses the use cache directive to mark cacheable functions:
async function getProductDetails(id: string) {
'use cache';
// This function's result is cached
const product = await db.products.findUnique({ where: { id } });
return product;
}
async function getInventory(id: string) {
// No directive - always fresh
const inventory = await inventoryService.check(id);
return inventory;
}When a page uses both functions, Next.js:
- Serves the cached product details instantly
- Streams the fresh inventory as it becomes available
- Combines them into a complete response
Building a Cached Product Page
Here's a complete product page using Cache Components:
// app/products/[id]/page.tsx
import { Suspense } from 'react';
import { unstable_cacheLife as cacheLife } from 'next/cache';
// Cached: Product details rarely change
async function ProductInfo({ id }: { id: string }) {
'use cache';
cacheLife('hours'); // Cache for hours
const product = await db.products.findUnique({
where: { id },
include: { category: true, brand: true },
});
return (
<div className="product-info">
<h1>{product.name}</h1>
<p className="brand">{product.brand.name}</p>
<p className="description">{product.description}</p>
<img src={product.imageUrl} alt={product.name} />
</div>
);
}
// Cached: Reviews change occasionally
async function ProductReviews({ id }: { id: string }) {
'use cache';
cacheLife('minutes'); // Cache for minutes
const reviews = await db.reviews.findMany({
where: { productId: id },
orderBy: { createdAt: 'desc' },
take: 10,
});
return (
<div className="reviews">
<h2>Reviews ({reviews.length})</h2>
{reviews.map(review => (
<ReviewCard key={review.id} review={review} />
))}
</div>
);
}
// Dynamic: Inventory changes constantly
async function InventoryStatus({ id }: { id: string }) {
// No cache directive - always fresh
const inventory = await inventoryService.getStock(id);
return (
<div className="inventory">
{inventory.inStock ? (
<span className="in-stock">In Stock ({inventory.quantity})</span>
) : (
<span className="out-of-stock">Out of Stock</span>
)}
</div>
);
}
// Dynamic: Price may vary by user/region
async function ProductPrice({ id }: { id: string }) {
const price = await pricingService.getPrice(id);
return (
<div className="price">
<span className="current">${price.current}</span>
{price.original > price.current && (
<span className="original">${price.original}</span>
)}
</div>
);
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return (
<div className="product-page">
{/* Static shell - served instantly */}
<ProductInfo id={id} />
{/* Dynamic parts stream in */}
<Suspense fallback={<PriceSkeleton />}>
<ProductPrice id={id} />
</Suspense>
<Suspense fallback={<InventorySkeleton />}>
<InventoryStatus id={id} />
</Suspense>
{/* Cached but refreshes periodically */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={id} />
</Suspense>
</div>
);
}Cache Life Configuration
Control how long cached content remains valid:
import { unstable_cacheLife as cacheLife } from 'next/cache';
async function getCachedData() {
'use cache';
// Predefined profiles
cacheLife('seconds'); // Short-lived cache
cacheLife('minutes'); // Moderate cache
cacheLife('hours'); // Long cache
cacheLife('days'); // Very long cache
cacheLife('weeks'); // Extended cache
cacheLife('max'); // Cache indefinitely
// Or custom configuration
cacheLife({
stale: 300, // Serve stale for 5 minutes
revalidate: 60, // Revalidate every minute
expire: 3600, // Expire after 1 hour
});
return await fetchData();
}Cache Tags for Invalidation
Tag cached content for targeted invalidation:
import { unstable_cacheTag as cacheTag, revalidateTag } from 'next/cache';
async function getProduct(id: string) {
'use cache';
cacheTag(`product-${id}`, 'products');
return await db.products.findUnique({ where: { id } });
}
async function getProductsByCategory(category: string) {
'use cache';
cacheTag(`category-${category}`, 'products');
return await db.products.findMany({ where: { category } });
}
// Invalidate specific product
async function updateProduct(id: string, data: ProductData) {
await db.products.update({ where: { id }, data });
revalidateTag(`product-${id}`);
}
// Invalidate all products
async function bulkUpdateProducts() {
await db.products.updateMany({ /* ... */ });
revalidateTag('products');
}How Streaming Works
When a user requests a product page:
- Instant response: Cached product info HTML streams immediately
- Loading states: Suspense boundaries show skeletons
- Dynamic streaming: Price and inventory stream as they resolve
- Complete page: All content visible within milliseconds of data availability
Timeline:
0ms - Request received
5ms - Cached ProductInfo HTML sent
10ms - Suspense skeletons rendered
50ms - ProductPrice resolved, streams in
80ms - InventoryStatus resolved, streams in
120ms - ProductReviews resolved, streams inUsers see content progressively rather than waiting for the slowest component.
Comparing Rendering Strategies
| Strategy | Static Content | Dynamic Content | TTFB | User Experience | |----------|---------------|-----------------|------|-----------------| | Static Generation | Pre-built | None | ~10ms | Instant but stale | | Dynamic Rendering | None | All fresh | ~200ms | Slow but fresh | | Cache Components | Cached | Streams fresh | ~10ms | Instant + fresh |
Cache Components gives you the TTFB of static generation with the freshness of dynamic rendering.
Server Actions with Caching
Server actions can also use caching:
'use server';
import { unstable_cacheTag as cacheTag, revalidateTag } from 'next/cache';
export async function getRecommendations(productId: string) {
'use cache';
cacheTag(`recommendations-${productId}`);
// Expensive ML recommendation calculation
const recommendations = await mlService.getRecommendations(productId);
return recommendations;
}
export async function addToCart(productId: string) {
// No cache - always executes
await cartService.add(productId);
// Invalidate recommendations since cart changed
revalidateTag(`recommendations-${productId}`);
}Edge Caching Integration
Cache Components work with Vercel's edge network:
async function getGlobalContent() {
'use cache';
cacheLife('days');
// This caches at the edge, served from nearest location
return await cms.getGlobalContent();
}
async function getRegionalContent(region: string) {
'use cache';
cacheTag(`region-${region}`);
// Cached per-region at the edge
return await cms.getRegionalContent(region);
}Edge caching means cached content serves from the user's nearest data center—often under 10ms globally.
Migration from PPR
If you used experimental PPR:
// Next.js 15 with PPR
export const experimental_ppr = true;
export default async function Page() {
const staticData = await getStaticData();
return (
<div>
<StaticPart data={staticData} />
<Suspense fallback={<Loading />}>
<DynamicPart />
</Suspense>
</div>
);
}
// Next.js 16 with Cache Components
async function StaticPart() {
'use cache';
const data = await getStaticData();
return <div>{/* render data */}</div>;
}
async function DynamicPart() {
// No directive - dynamic
const data = await getDynamicData();
return <div>{/* render data */}</div>;
}
export default async function Page() {
return (
<div>
<StaticPart />
<Suspense fallback={<Loading />}>
<DynamicPart />
</Suspense>
</div>
);
}The key change: move caching decisions to the data-fetching functions rather than the route level.
Debugging Cache Behavior
Next.js 16 DevTools show cache status:
Request: /products/123
├── ProductInfo: CACHE HIT (age: 45min, ttl: 15min)
├── ProductPrice: DYNAMIC (45ms)
├── InventoryStatus: DYNAMIC (23ms)
└── ProductReviews: CACHE HIT (age: 3min, ttl: 7min)Or use headers to inspect cache behavior:
curl -I https://example.com/products/123
# Response headers show cache status
x-nextjs-cache: HIT
x-cache-status: stale-while-revalidatePerformance Impact
Real metrics from migrating our e-commerce site:
Before (Dynamic Rendering)
- TTFB: 280ms
- LCP: 1.8s
- Server load: 100 requests/second max
After (Cache Components)
- TTFB: 15ms (cached) / 180ms (dynamic parts)
- LCP: 0.6s
- Server load: 2000+ requests/second
The static shell serves instantly from cache. Users perceive the page as loaded while dynamic content streams in. Server load drops dramatically because most requests hit cache rather than computing fresh responses.
Cache Components represent Next.js's answer to the static vs dynamic tradeoff. By making caching granular at the function level, you get precise control over what's cached and what's fresh—without choosing one strategy for the entire page.