Next.js 16 Cache Components and Partial Pre-Rendering

7 min read1307 words

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:

  1. Serves the cached product details instantly
  2. Streams the fresh inventory as it becomes available
  3. 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:

  1. Instant response: Cached product info HTML streams immediately
  2. Loading states: Suspense boundaries show skeletons
  3. Dynamic streaming: Price and inventory stream as they resolve
  4. 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 in

Users 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-revalidate

Performance 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.