MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js Cache Components and PPR: Advanced Caching

Deep dive into Next.js caching: Cache Components, PPR, and ISR working together.

Next.jsCachingPPRPerformance

By MinhVo

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.

Next.js caching architecture

Understanding Caching Layers: Core Concepts

The Three Caching Strategies

Next.js provides three complementary caching mechanisms:

StrategyGranularityRevalidationUse Case
ISRPage/route levelTime-based or on-demandContent that changes periodically
PPRComponent level within a pageStatic shell + dynamic streamsPages with mixed static/dynamic content
Cache ComponentsIndividual component levelCacheLife policiesReusable 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:

  1. Build time: Page is pre-rendered and cached
  2. First request after revalidate period: Serve stale page, trigger background rebuild
  3. Subsequent requests: Serve the newly rebuilt page
  4. 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.

Caching strategy comparison

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>
  );
}

Implementation architecture

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

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

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

  3. Apply Cache Components to shared UI: Components like navigation, footers, and category lists that appear on multiple pages benefit most from component-level caching.

  4. Use CacheTags for granular invalidation: Tag each cacheable component with specific tags so you can invalidate precisely without affecting unrelated components.

  5. Define CacheLife policies consistently: Create a centralized set of cache policies (e.g., "minutes", "hours", "days") and apply them consistently across your application.

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

  7. Test cache invalidation paths: Write integration tests that verify revalidateTag and revalidatePath correctly invalidate cached content without breaking other cached components.

  8. Use unstable_cache for non-component caching: For data fetching outside of components (e.g., in utility functions), use unstable_cache with explicit key generation and revalidation options.

Common Pitfalls and Solutions

PitfallImpactSolution
Caching user-specific data in Cache ComponentsUsers see other users' dataNever use "use cache" for user-specific content; use PPR dynamic streams instead
Forgetting to define defaultCacheLifeComponents use aggressive defaultsAlways explicitly set cacheLife in cached components
ISR revalidation race conditionsStale content served during rebuildUse on-demand revalidation (revalidateTag) for critical updates
PPR not working in developmentConfusion during developmentPPR requires production build (next build && next start) to see full effect
Over-caching dynamic contentUsers see outdated informationUse shorter revalidation periods for frequently changing data
Cache tag naming collisionsWrong components invalidatedUse 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

FeatureISRPPRCache ComponentsCDN Caching
GranularityPagePage shell + holesComponentURL path
RevalidationTime/on-demandPer-streamCacheLife policyTTL headers
User-specificNoYes (dynamic streams)NoNo
Setup complexityLowMediumMediumHigh
Best forBlog posts, docsDashboards, feedsShared UI, navStatic 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:

  1. Use ISR for content-heavy pages that change periodically but don't need real-time freshness.
  2. Use PPR for pages with mixed static and dynamic content — the static shell loads instantly while dynamic data streams in.
  3. Use Cache Components for shared UI elements like navigation, footers, and category lists that rarely change.
  4. 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.
  5. 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.