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: Fine-Grained Caching Control

Control caching at the component level: CacheLife, CacheTag, and purge strategies.

Next.jsCachingPerformanceFrontend

By MinhVo

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.

Component-level caching architecture

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} />;
}
ParameterPurposeDefault
staleHow long to serve stale content without revalidation300s (5 min)
revalidateHow often to check for fresh content300s (5 min)
expireMaximum time to serve cached content before hard refresh86400s (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: 30d

CacheTag: 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");

Cache invalidation flow

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

Implementation workflow

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

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

  2. Use hierarchical CacheTags: Structure tags as type-${id} and type-list so you can invalidate individual items or entire collections with a single call.

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

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

  5. Monitor cache hit rates: Log cache hits and misses to identify components that are being invalidated too frequently or cached for too long.

  6. Test cache invalidation: Write integration tests that verify revalidateTag correctly invalidates the right components without affecting unrelated cached content.

  7. Use expire as a safety net: Even if revalidate is short, set expire to a reasonable maximum to prevent serving very stale content if revalidation fails.

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

PitfallImpactSolution
Caching user-specific dataUsers see other users' dataUse PPR dynamic streams for user content; never use "use cache" for personalized data
Missing CacheTagsCan't invalidate specific componentsAlways add relevant tags: product-${id}, blog-${slug}
Overly aggressive cachingStale prices or stock shown to usersUse cacheLife("minutes") for frequently changing data
Cache stampede on invalidationServer overwhelmed after revalidationUse stale period to serve cached content while revalidating in background
Forgetting expireCached content never refreshes if revalidation failsAlways set expire as a safety net
Invalidating too broadlyPerformance hit from rebuilding unrelated componentsUse 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

ApproachGranularityInvalidationComplexityBest For
Cache ComponentsComponentTag-basedMediumShared UI, reusable components
ISRPageTime/on-demandLowContent-heavy pages
PPRPage sectionsAlways freshMediumUser-specific dynamic content
CDN cachingURLTTL headersHighStatic assets, API responses
React QueryClient stateManual/AutoLowClient-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:

  1. Use "use cache" to mark components as cacheable — their output is stored and reused across requests.
  2. Define CacheLife policies explicitly for every cached component based on its data freshness requirements.
  3. Use CacheTags for targeted invalidation — invalidate specific items with product-${id} or entire collections with product-list.
  4. Connect webhooks to cache invalidation so your cache stays fresh automatically when data changes in your CMS or database.
  5. 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.