Next.js Cache Components Skill
This skill helps you implement and optimize Next.js 16 Cache Components in apps/web/ .
When to Use This Skill
-
Converting components to use Cache Components
-
Adding "use cache" directives to functions or queries
-
Implementing query-level caching for data fetching
-
Setting up domain-level cache tags for efficient invalidation
-
Implementing Suspense boundaries
-
Configuring cacheLife profiles
-
Debugging cache-related errors
-
Optimizing page load performance
-
Reducing ISR write operations with broad cache tags
Cache Components Overview
Next.js 16 introduced Cache Components to improve performance by caching component render results.
Key Concepts
-
"use cache" directive: Marks functions/components for caching
-
Query-level caching: Apply cache directives at data-fetching query functions for consistency
-
Domain-level cache tags: Use broad tags (e.g., CACHE_LIFE.cars ) instead of granular per-record tags
-
Suspense boundaries: Required for streaming cached content
-
cacheLife profiles: Control cache duration and invalidation
-
cacheTag: Manual cache invalidation (prefer domain-level scopes)
-
Dynamic vs Static: Understanding when caching applies
Implementation Patterns
- Basic Cache Component
// app/components/car-list.tsx import { Suspense } from "react";
async function CarList() { "use cache";
const cars = await db.query.cars.findMany();
return ( <div> {cars.map(car => ( <div key={car.id}>{car.make} {car.model}</div> ))} </div> ); }
// Parent component with Suspense export default function CarsPage() { return ( <Suspense fallback={<div>Loading cars...</div>}> <CarList /> </Suspense> ); }
- Cache with cacheLife
Control cache duration:
import { cacheLife } from "next/cache";
async function COEData() { "use cache"; cacheLife("hours"); // Built-in profile: cache for hours
const coe = await db.query.coe.findMany(); return <COETable data={coe} />; }
// Custom cache profile async function RealtimeData() { "use cache"; cacheLife({ stale: 60, // Serve stale for 60 seconds revalidate: 300, // Revalidate after 5 minutes expire: 3600, // Expire after 1 hour });
const data = await fetchRealtimeData(); return <DataDisplay data={data} />; }
- Cache Tags for Invalidation
Domain-Level Scopes (Recommended)
Use broad, domain-level cache tags to minimize ISR write operations:
// lib/cache.ts - Define domain-level cache scopes export const CACHE_LIFE = { cars: "cars", coe: "coe", posts: "posts", } as const;
// Query function with domain-level cache tag import { CACHE_LIFE } from "@web/lib/cache"; import { cacheLife, cacheTag } from "next/cache";
export const getDistinctMakes = async () => { "use cache"; cacheLife("max"); cacheTag(CACHE_LIFE.cars); // Broad tag for all car-related data
return db.selectDistinct({ make: cars.make }).from(cars).orderBy(cars.make); };
// Invalidate entire domain cache in server action "use server"; import { revalidateTag } from "next/cache"; import { CACHE_LIFE } from "@web/lib/cache";
export async function updateCarData() { await fetchAndUpdateCars(); revalidateTag(CACHE_LIFE.cars); // Invalidate all car-related caches }
Granular Tags (Use Sparingly)
For specific invalidation needs, use granular tags:
async function BlogPosts() { "use cache"; cacheTag("blog-posts"); // Tag for manual invalidation
const posts = await db.query.posts.findMany(); return <PostList posts={posts} />; }
// Invalidate cache in server action "use server"; import { revalidateTag } from "next/cache";
export async function createPost(data: PostData) { await db.insert(posts).values(data); revalidateTag("blog-posts"); // Invalidate cached blog posts }
⚠️ Cache Tag Best Practices:
-
Prefer domain-level tags to reduce ISR write operations
-
Avoid over-granular tags (e.g., per-record tags) which increase overhead
-
Use granular tags only when selective invalidation is critical
-
Keep tag names consistent using constants from lib/cache.ts
- Query-Level Caching (Recommended Pattern)
Apply cache directives at the query function level for reusable data fetching:
// queries/cars/filter-options.ts import { cars, db } from "@sgcarstrends/database"; import { CACHE_LIFE } from "@web/lib/cache"; import { cacheLife, cacheTag } from "next/cache";
export const getDistinctMakes = async () => { "use cache"; cacheLife("max"); cacheTag(CACHE_LIFE.cars);
return db.selectDistinct({ make: cars.make }).from(cars).orderBy(cars.make); };
export const getDistinctFuelTypes = async (month?: string) => { "use cache"; cacheLife("max"); cacheTag(CACHE_LIFE.cars);
const filters = month ? [eq(cars.month, month)] : []; return db .selectDistinct({ fuelType: cars.fuelType }) .from(cars) .where(filters.length > 0 ? and(...filters) : undefined) .orderBy(cars.fuelType); };
Benefits:
-
✅ Centralized cache configuration for all data queries
-
✅ Consistent caching strategy across the application
-
✅ Easy to maintain and update cache policies
-
✅ Reusable across multiple components/pages
-
✅ Domain-level cache tags for efficient invalidation
Usage in Components:
// app/cars/page.tsx import { getDistinctMakes } from "@web/queries/cars/filter-options";
export default async function CarsPage() { const makes = await getDistinctMakes(); // Cached automatically
return <MakesList makes={makes} />; }
- Private Cache (User-Specific)
import { cookies } from "next/headers";
async function UserDashboard() { "use cache: private"; // Cache per-user, not globally
const cookieStore = await cookies(); const userId = cookieStore.get("userId")?.value; const userData = await fetchUserData(userId);
return <Dashboard data={userData} />; }
- Nested Cache Components
// Outer component - longer cache async function CarsByMake({ make }: { make: string }) { "use cache"; cacheLife("days");
const cars = await db.query.cars.findMany({ where: eq(cars.make, make), });
return ( <div> <h2>{make} Models</h2> <Suspense fallback={<div>Loading stats...</div>}> <CarStats makeId={make} /> </Suspense> {cars.map(car => <CarCard key={car.id} car={car} />)} </div> ); }
// Inner component - shorter cache async function CarStats({ makeId }: { makeId: string }) { "use cache"; cacheLife("minutes");
const stats = await calculateStats(makeId); return <StatsDisplay stats={stats} />; }
Common Tasks
Converting Existing Component to Cache Component
Before:
// Regular server component async function DataTable() { const data = await fetchData(); return <Table data={data} />; }
After:
// Cached server component async function DataTable() { "use cache"; cacheLife("hours"); cacheTag("data-table");
const data = await fetchData(); return <Table data={data} />; }
// Parent with Suspense export default function Page() { return ( <Suspense fallback={<TableSkeleton />}> <DataTable /> </Suspense> ); }
Debugging Cache Issues
Component not caching:
-
Verify "use cache" is first line
-
Check Suspense boundary exists
-
Ensure running Next.js 16+
-
Check dev server logs
Cache not invalidating:
-
Verify cacheTag is set correctly
-
Check revalidateTag is called
-
Review cacheLife settings
-
Check if using "private" cache incorrectly
Runtime errors:
-
Use Next.js runtime MCP tool to check errors
-
Review browser console for hydration issues
-
Check server logs for cache misses
Use Next.js DevTools MCP:
// Get runtime errors mcp__next-devtools__nextjs_runtime({ action: "call_tool", toolName: "get_errors" })
Performance Optimization
Identify cache opportunities:
-
Expensive data fetches: Wrap in "use cache"
-
Static content: Use long cacheLife
-
Frequently accessed: Cache with appropriate TTL
-
User-specific: Use "use cache: private"
Built-in cacheLife Profiles
Next.js provides these profiles:
"seconds" // { stale: 1, revalidate: 10, expire: 60 } "minutes" // { stale: 60, revalidate: 300, expire: 3600 } "hours" // { stale: 3600, revalidate: 86400, expire: 604800 } "days" // { stale: 86400, revalidate: 604800, expire: 2592000 } "weeks" // { stale: 604800, revalidate: 2592000, expire: 31536000 } "max" // { stale: 2592000, revalidate: 31536000, expire: Infinity }
Suspense Best Practices
- Loading States
Provide meaningful fallbacks:
<Suspense fallback={<CarListSkeleton />}> <CarList /> </Suspense>
- Multiple Suspense Boundaries
Stream different parts independently:
export default function Dashboard() { return ( <> <Suspense fallback={<HeaderSkeleton />}> <Header /> </Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Charts />
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<DataTable />
</Suspense>
</>
); }
- Error Boundaries with Suspense
import { ErrorBoundary } from "react-error-boundary";
export default function Page() { return ( <ErrorBoundary fallback={<ErrorDisplay />}> <Suspense fallback={<Loading />}> <DataComponent /> </Suspense> </ErrorBoundary> ); }
Testing Cache Components
// tests/car-list.test.tsx import { render, screen } from "@testing-library/react"; import { Suspense } from "react"; import CarList from "../car-list";
describe("CarList", () => { it("renders with Suspense", async () => { render( <Suspense fallback={<div>Loading...</div>}> <CarList /> </Suspense> );
// Initially shows fallback
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for component to load
const content = await screen.findByText(/Toyota/i);
expect(content).toBeInTheDocument();
}); });
Common Errors and Solutions
Error: "use cache" must be first directive
Wrong:
async function Component() { const data = await fetchData(); "use cache"; // ❌ Too late }
Correct:
async function Component() { "use cache"; // ✅ First line const data = await fetchData(); }
Error: Missing Suspense boundary
Wrong:
export default function Page() { return <CachedComponent />; // ❌ No Suspense }
Correct:
export default function Page() { return ( <Suspense fallback={<Loading />}> <CachedComponent /> </Suspense> ); }
Error: Dynamic rendering disables cache
Avoid dynamic APIs in cached components:
Wrong:
async function Component() { "use cache"; const headers = await headers(); // ❌ Dynamic API }
Correct:
// Move dynamic logic to parent export default async function Page() { const headers = await headers(); const userId = headers.get("x-user-id");
return ( <Suspense fallback={<Loading />}> <CachedComponent userId={userId} /> </Suspense> ); }
async function CachedComponent({ userId }: { userId: string }) { "use cache"; // Now receives userId as prop }
Documentation References
Always check the latest Next.js docs:
// Query Next.js documentation mcp__next-devtools__nextjs_docs({ action: "get", path: "/docs/app/api-reference/directives/use-cache" })
Performance Monitoring
Track cache effectiveness:
-
Check Next.js build output for cached routes
-
Monitor server response times
-
Use Next.js Analytics for performance metrics
-
Review cache hit/miss ratios in logs
References
-
Next.js 16 Cache Components: Use nextjs_docs MCP tool
-
Related files:
-
apps/web/src/app/
-
All Next.js pages
-
apps/web/src/components/
-
React components
-
apps/web/CLAUDE.md
-
Web app documentation
Best Practices
-
Query-Level Caching: Apply cache directives at query functions for consistency and reusability
-
Domain-Level Tags: Use broad cache tags (e.g., CACHE_LIFE.cars ) to minimize ISR write operations
-
Avoid Over-Granular Tags: Don't create per-record or per-field tags; use domain scopes instead
-
Appropriate TTLs: Match cache duration to data freshness needs (use cacheLife("max") for static data)
-
Cache Tag Constants: Define cache tags in lib/cache.ts for consistency
-
Error Handling: Always wrap cached components with ErrorBoundary
-
Loading States: Provide meaningful Suspense fallbacks
-
Testing: Test both cached and uncached states
-
Monitoring: Track cache performance in production
-
Progressive Enhancement: Start with long TTLs, optimize based on usage
Cache Optimization Strategy
Recommended Approach (SG Cars Trends Pattern):
// 1. Define domain-level cache tags // lib/cache.ts export const CACHE_LIFE = { cars: "cars", coe: "coe", posts: "posts", } as const;
// 2. Apply cache directives at query level // queries/cars/filter-options.ts export const getDistinctMakes = async () => { "use cache"; cacheLife("max"); cacheTag(CACHE_LIFE.cars);
return db.selectDistinct({ make: cars.make }).from(cars); };
// 3. Invalidate at domain level // actions/update-cars.ts "use server"; import { revalidateTag } from "next/cache"; import { CACHE_LIFE } from "@web/lib/cache";
export async function updateCarData() { await fetchAndUpdateCars(); revalidateTag(CACHE_LIFE.cars); // Invalidate all car-related caches }
Benefits:
-
✅ Reduces ISR write operations
-
✅ Simplifies cache invalidation
-
✅ Improves performance
-
✅ Easier to maintain