Designing a Caching Strategy for Next.js That Actually Makes Sense
Designing a Caching Strategy for Next.js That Actually Makes Sense
Next.js has one of the most sophisticated—and confusing—caching systems in the frontend ecosystem. Five different caches, each with different invalidation rules, different scopes, and different mental models. Most teams stumble through trial and error, adding revalidate here, cache: 'no-store' there, until things seem to work.
This is an architectural guide to understanding what actually caches what, when, and how to design a coherent strategy instead of playing whack-a-mole with stale data.
The Five Caches You're Actually Dealing With
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS CACHING LAYERS │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Browser │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ ROUTER CACHE (Client-side) │ │
│ │ - RSC Payload cached in memory │ │
│ │ - Duration: Session (dynamic) or 5 min (static) │ │
│ │ - Scope: Per user, per browser tab │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Server │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ FULL ROUTE CACHE │ │
│ │ - Complete rendered HTML + RSC Payload │ │
│ │ - Duration: Until revalidation or redeployment │ │
│ │ - Scope: Shared across all users │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ REQUEST MEMOIZATION │ │
│ │ - Dedupes identical fetch() calls in single render │ │
│ │ - Duration: Single request/render lifecycle │ │
│ │ - Scope: Per request │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ DATA CACHE (fetch cache) │ │
│ │ - Cached fetch() responses │ │
│ │ - Duration: Indefinite (until revalidation) │ │
│ │ - Scope: Shared across requests and deployments │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ unstable_cache │ │
│ │ - Cached arbitrary function results │ │
│ │ - Duration: Indefinite (until revalidation) │ │
│ │ - Scope: Shared across requests and deployments │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Let's dissect each one.
Layer 1: Request Memoization
The simplest cache—and the one that causes the least confusion. When you call fetch() multiple times with identical arguments during a single render pass, Next.js automatically deduplicates them.
// layout.tsx
async function Layout({ children }) {
const user = await fetch('/api/user'); // Request 1
return <div>{children}</div>;
}
// page.tsx
async function Page() {
const user = await fetch('/api/user'); // Same request - DEDUPED
return <UserProfile user={user} />;
}
// Both components call the same endpoint
// Only ONE actual network request is made
What Gets Memoized
┌─────────────────────────────────────────────────────────────────┐
│ MEMOIZATION KEY COMPONENTS │
├─────────────────────────────────────────────────────────────────┤
│ │
│ fetch(url, options) │
│ │ │ │
│ │ └─► method, headers, body, etc. │
│ │ (everything must match) │
│ │ │
│ └─► Full URL including query params │
│ │
│ ✓ fetch('/api/user') ──┐ │
│ ✓ fetch('/api/user') ──┼─► Same key │
│ ✗ fetch('/api/user?v=1') ──┘ (memoized) │
│ │ │
│ ✗ fetch('/api/user', { method: 'POST' })──┴─► Different key │
│ │
└─────────────────────────────────────────────────────────────────┘
Critical Limitation: fetch() Only
Request memoization only works with fetch(). Direct database calls, ORM queries, or third-party SDK calls are NOT memoized:
// These are NOT automatically memoized:
const user1 = await prisma.user.findUnique({ where: { id } });
const user2 = await prisma.user.findUnique({ where: { id } });
// Two separate database queries
// Solution: Use React's cache()
import { cache } from 'react';
const getUser = cache(async (id: string) => {
return prisma.user.findUnique({ where: { id } });
});
// Now memoized within the same render
const user1 = await getUser(id);
const user2 = await getUser(id); // Returns cached result
React cache() vs Request Memoization
import { cache } from 'react';
// React cache() - manual memoization for non-fetch operations
export const getUser = cache(async (id: string) => {
console.log('Fetching user:', id);
return db.user.findUnique({ where: { id } });
});
// Scope: Single request/render tree
// Reset: Every new request starts fresh
// Use for: Database queries, computations, SDK calls
Layer 2: Data Cache (The fetch Cache)
This is where things get interesting—and confusing. The Data Cache persists fetch responses across requests and even deployments.
Default Behavior (Next.js 15+)
// Next.js 15: fetch is NOT cached by default
const data = await fetch('https://api.example.com/data');
// Every request hits the API
// Opt INTO caching:
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache', // Explicitly cache
});
// Next.js 14 and earlier: fetch IS cached by default
// Opt OUT of caching:
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
});
The Cache Options Explained
// Option 1: force-cache (cache indefinitely)
fetch(url, { cache: 'force-cache' });
// Cached until: manual revalidation or redeployment
// Option 2: no-store (never cache)
fetch(url, { cache: 'no-store' });
// Always fresh, always hits origin
// Option 3: Time-based revalidation
fetch(url, { next: { revalidate: 60 } });
// Cached for 60 seconds, then stale-while-revalidate
// Option 4: Tag-based revalidation
fetch(url, { next: { tags: ['products'] } });
// Cached until revalidateTag('products') is called
Stale-While-Revalidate in Action
┌─────────────────────────────────────────────────────────────────────────────┐
│ TIME-BASED REVALIDATION (revalidate: 60) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Timeline: │
│ ─────────────────────────────────────────────────────────────────────► │
│ │ │
│ │ Request 1 (t=0s) │
│ │ ├─► Cache MISS │
│ │ ├─► Fetch from origin │
│ │ └─► Store in cache (fresh until t=60s) │
│ │ │
│ │ Request 2 (t=30s) │
│ │ ├─► Cache HIT │
│ │ └─► Return cached data (still fresh) │
│ │ │
│ │ Request 3 (t=65s) │
│ │ ├─► Cache HIT (data is STALE) │
│ │ ├─► Return stale data immediately │
│ │ └─► Background: Revalidate and update cache │
│ │ │
│ │ Request 4 (t=66s) │
│ │ ├─► Cache HIT │
│ │ └─► Return fresh data (from background revalidation) │
│ │ │
└─────────────────────────────────────────────────────────────────────────────┘
Tag-Based Revalidation: The Power Tool
// In your data fetching:
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { tags: ['products', 'inventory'] },
});
return res.json();
}
async function getProduct(id: string) {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: ['products', `product-${id}`] },
});
return res.json();
}
// In your Server Action or Route Handler:
import { revalidateTag } from 'next/cache';
async function updateProduct(id: string, data: ProductData) {
await db.product.update({ where: { id }, data });
// Surgical invalidation
revalidateTag(`product-${id}`); // Just this product
// OR
revalidateTag('products'); // All products
// OR
revalidateTag('inventory'); // Everything with inventory tag
}
Tag Taxonomy: Designing Your Invalidation Strategy
┌─────────────────────────────────────────────────────────────────────────────┐
│ TAG HIERARCHY PATTERN │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Level 1: Domain │
│ └─► 'products' | 'users' | 'orders' | 'content' │
│ │
│ Level 2: Entity │
│ └─► 'product-123' | 'user-456' | 'order-789' │
│ │
│ Level 3: Aspect │
│ └─► 'product-123-reviews' | 'product-123-inventory' │
│ │
│ Cross-cutting: │
│ └─► 'pricing' | 'availability' | 'user-generated' │
│ │
│ Example fetch with multiple tags: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ fetch(`/api/products/${id}/reviews`, { │ │
│ │ next: { │ │
│ │ tags: [ │ │
│ │ 'products', // Invalidate all products │ │
│ │ `product-${id}`, // Invalidate this product │ │
│ │ `product-${id}-reviews`, // Invalidate just reviews │ │
│ │ 'user-generated', // Invalidate all UGC │ │
│ │ ] │ │
│ │ } │ │
│ │ }) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Layer 3: Full Route Cache
The Full Route Cache stores the complete rendered output of static routes—both HTML and RSC Payload.
What Gets Full Route Cached
// STATIC (Full Route Cached by default)
// page.tsx
export default function AboutPage() {
return <div>About us</div>;
}
// Rendered at build time, cached indefinitely
// STATIC with data fetching
export default async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
cache: 'force-cache',
});
return <ProductList products={products} />;
}
// Rendered at build time if all data is cached
// DYNAMIC (Opted out of Full Route Cache)
export default async function DashboardPage() {
const user = await fetch('/api/user', { cache: 'no-store' });
return <Dashboard user={user} />;
}
// Rendered on every request
What Opts You Out of Full Route Cache
┌─────────────────────────────────────────────────────────────────────────────┐
│ TRIGGERS FOR DYNAMIC RENDERING │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Automatic Dynamic Functions: │
│ ├─► cookies() │
│ ├─► headers() │
│ ├─► searchParams (in page props) │
│ └─► connection() / draftMode() │
│ │
│ Data Fetching: │
│ ├─► fetch(url, { cache: 'no-store' }) │
│ └─► Any uncached fetch (Next.js 15+) │
│ │
│ Route Segment Config: │
│ ├─► export const dynamic = 'force-dynamic' │
│ ├─► export const revalidate = 0 │
│ └─► Using unstable_noStore() │
│ │
│ One dynamic trigger = entire route is dynamic │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Dynamic Contamination Problem
// layout.tsx (DYNAMIC because it reads cookies)
import { cookies } from 'next/headers';
export default async function Layout({ children }) {
const theme = (await cookies()).get('theme')?.value;
return <div data-theme={theme}>{children}</div>;
}
// page.tsx (wants to be STATIC)
export default async function ProductsPage() {
// Even though this fetch is cached...
const products = await fetch('https://api.example.com/products', {
cache: 'force-cache',
});
// ...the page is DYNAMIC because the layout uses cookies()
return <ProductList products={products} />;
}
Solution: Isolate Dynamic Parts
// layout.tsx (STATIC now)
export default function Layout({ children }) {
return (
<div>
<ThemeProvider /> {/* Client Component reads cookie */}
{children}
</div>
);
}
// ThemeProvider.tsx (Client Component)
'use client';
export function ThemeProvider({ children }) {
const theme = useCookieValue('theme'); // Client-side cookie read
return <div data-theme={theme}>{children}</div>;
}
// Now the layout doesn't use cookies() server-side
// ProductsPage can be statically rendered
Layer 4: unstable_cache (The Escape Hatch)
When you can't use fetch() but need caching—database queries, SDK calls, computations—unstable_cache is your tool.
import { unstable_cache } from 'next/cache';
// Basic usage
const getCachedUser = unstable_cache(
async (userId: string) => {
return prisma.user.findUnique({
where: { id: userId },
include: { posts: true },
});
},
['user'], // Cache key prefix
{
tags: ['users'], // For tag-based invalidation
revalidate: 3600, // Time-based revalidation (seconds)
}
);
// In your component
export default async function UserProfile({ userId }) {
const user = await getCachedUser(userId);
return <Profile user={user} />;
}
Cache Key Construction
┌─────────────────────────────────────────────────────────────────────────────┐
│ unstable_cache KEY STRUCTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ unstable_cache(fn, keyParts, options) │
│ │ │
│ └─► Array of strings that form the cache key prefix │
│ │
│ Final cache key = [...keyParts, ...serializedArguments] │
│ │
│ Example: │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ const getProduct = unstable_cache( │ │
│ │ async (id: string, locale: string) => db.product.find(id, locale),│ │
│ │ ['products'], // keyParts │ │
│ │ { tags: ['products'] } │ │
│ │ ); │ │
│ │ │ │
│ │ getProduct('123', 'en') // Key: ['products', '123', 'en'] │ │
│ │ getProduct('123', 'fr') // Key: ['products', '123', 'fr'] │ │
│ │ getProduct('456', 'en') // Key: ['products', '456', 'en'] │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Arguments MUST be serializable (no functions, circular refs, etc.) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Common Patterns
// Pattern 1: Parameterized cache with tags
const getProductWithRelations = unstable_cache(
async (productId: string) => {
return prisma.product.findUnique({
where: { id: productId },
include: {
category: true,
reviews: { take: 10, orderBy: { createdAt: 'desc' } },
variants: true,
},
});
},
['product-with-relations'],
{
tags: ['products'],
revalidate: 300, // 5 minutes
}
);
// Pattern 2: Dynamic tags based on arguments
function getCachedProduct(productId: string) {
return unstable_cache(
async () => prisma.product.findUnique({ where: { id: productId } }),
['product', productId],
{ tags: ['products', `product-${productId}`] }
)();
}
// Pattern 3: Aggregations with longer cache times
const getProductStats = unstable_cache(
async () => {
return prisma.product.aggregate({
_count: true,
_avg: { price: true },
});
},
['product-stats'],
{
tags: ['products', 'stats'],
revalidate: 3600, // 1 hour - stats don't need to be real-time
}
);
The "Unstable" Reality
Despite the name, unstable_cache is production-ready. The "unstable" prefix indicates the API might change, not that it's unreliable. However, be aware:
// Gotcha 1: No automatic request deduplication
// Unlike fetch, calling the same cached function multiple times
// in one render DOES hit the cache multiple times (though it's fast)
// Gotcha 2: Serialization requirements
const getCachedData = unstable_cache(
async (filter: ProductFilter) => { /* ... */ },
['products'],
);
// filter must be JSON-serializable
// Dates become strings, functions are stripped, etc.
// Gotcha 3: Cache key includes ALL arguments
const bad = unstable_cache(
async (id: string, options: { includeDeleted?: boolean }) => { /* ... */ },
['items'],
);
bad('123', { includeDeleted: true }); // Different cache entry
bad('123', { includeDeleted: false }); // Different cache entry
bad('123', {}); // Different cache entry
// Solution: Normalize or separate
const getItem = unstable_cache(
async (id: string, includeDeleted: boolean = false) => { /* ... */ },
['items'],
);
Layer 5: Router Cache (Client-Side)
The most misunderstood cache. The Router Cache stores RSC Payloads on the client to enable instant back/forward navigation.
How It Works
┌─────────────────────────────────────────────────────────────────────────────┐
│ ROUTER CACHE BEHAVIOR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ User navigates: /products → /products/123 │
│ │
│ Step 1: Prefetch (on link hover/viewport) │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ <Link href="/products/123">View Product</Link> │ │
│ │ │ │
│ │ Browser prefetches RSC payload for /products/123 │ │
│ │ Stored in Router Cache (client memory) │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Step 2: Navigation │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ User clicks link │ │
│ │ Router Cache HIT → Instant navigation │ │
│ │ No network request needed │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ Step 3: Back navigation │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ User clicks back │ │
│ │ Router Cache HIT for /products → Instant │ │
│ │ Scroll position restored │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Cache Duration (Next.js 15)
// STATIC routes: 5 minutes
// DYNAMIC routes: 0 seconds (no caching)
// This changed in Next.js 15!
// Previously, dynamic routes cached for 30 seconds
// Now they don't cache at all by default
// Override with configuration:
// next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 30, // Cache dynamic routes for 30s
static: 180, // Cache static routes for 3 minutes
},
},
};
Invalidating the Router Cache
// From Server Actions:
import { revalidatePath, revalidateTag } from 'next/cache';
async function updateProduct(id: string, data: FormData) {
'use server';
await db.product.update({ where: { id }, data });
// These invalidate BOTH server caches AND Router Cache
revalidatePath(`/products/${id}`);
// OR
revalidateTag(`product-${id}`);
}
// From Client Components:
import { useRouter } from 'next/navigation';
function ProductActions({ productId }) {
const router = useRouter();
async function handleUpdate() {
await updateProduct(productId);
router.refresh(); // Refreshes current route, clears Router Cache for it
}
}
The Stale Data Problem
// Scenario: User on /products, another tab updates inventory
// Problem:
// 1. User is viewing /products (data fetched, Router Cache populated)
// 2. Inventory changes via another tab/user
// 3. User navigates away and back
// 4. Router Cache serves stale data
// Solutions:
// Solution 1: Reduce cache time for volatile data
// next.config.js
module.exports = {
experimental: {
staleTimes: {
dynamic: 0, // No client caching for dynamic routes
},
},
};
// Solution 2: Polling/real-time updates (Client Component)
'use client';
import useSWR from 'swr';
function InventoryDisplay({ productId }) {
const { data } = useSWR(
`/api/products/${productId}/inventory`,
fetcher,
{ refreshInterval: 30000 } // Poll every 30s
);
return <span>{data?.quantity} in stock</span>;
}
// Solution 3: Opt specific routes out of prefetching
<Link href="/products" prefetch={false}>
Products (always fresh)
</Link>
Designing a Coherent Caching Architecture
Now that we understand each layer, let's design a system that makes sense.
Step 1: Classify Your Data
┌─────────────────────────────────────────────────────────────────────────────┐
│ DATA CLASSIFICATION MATRIX │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ │ Shared Across Users │ User-Specific │
│ ───────────┼─────────────────────┼──────────────────── │
│ Static │ Product info, │ N/A (user data is │
│ (rarely │ CMS content, │ inherently dynamic) │
│ changes) │ Categories │ │
│ │ → force-cache │ │
│ │ → tags for updates │ │
│ ───────────┼─────────────────────┼──────────────────── │
│ Dynamic │ Inventory levels, │ User preferences, │
│ (changes │ Pricing, │ Cart contents, │
│ frequently)│ Reviews, Ratings │ Order history │
│ │ → revalidate: N │ → no-store │
│ │ → short TTL │ → client-side fetch │
│ ───────────┼─────────────────────┼──────────────────── │
│ Real-time │ Live scores, │ Notifications, │
│ (seconds │ Stock tickers │ Messages │
│ matter) │ → no-store + │ → WebSocket/SSE │
│ │ client polling │ → no caching │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Step 2: Create a Caching Service Layer
// lib/cache/index.ts
import { unstable_cache } from 'next/cache';
import { cache } from 'react';
// Cache configuration by data type
const CACHE_CONFIG = {
products: {
revalidate: 300, // 5 minutes
tags: ['products'],
},
inventory: {
revalidate: 30, // 30 seconds
tags: ['inventory'],
},
categories: {
revalidate: 3600, // 1 hour
tags: ['categories'],
},
user: {
revalidate: false, // No server cache
},
} as const;
// Type-safe cache factory
export function createCachedQuery<TArgs extends unknown[], TResult>(
queryFn: (...args: TArgs) => Promise<TResult>,
keyPrefix: string[],
config: keyof typeof CACHE_CONFIG | { revalidate?: number; tags?: string[] }
) {
const cacheConfig = typeof config === 'string' ? CACHE_CONFIG[config] : config;
if (cacheConfig.revalidate === false) {
// Request memoization only, no persistent cache
return cache(queryFn);
}
return unstable_cache(queryFn, keyPrefix, {
revalidate: cacheConfig.revalidate,
tags: cacheConfig.tags,
});
}
// Usage
export const getProduct = createCachedQuery(
async (id: string) => prisma.product.findUnique({ where: { id } }),
['product'],
'products'
);
export const getInventory = createCachedQuery(
async (productId: string) => prisma.inventory.findUnique({ where: { productId } }),
['inventory'],
'inventory'
);
export const getCurrentUser = createCachedQuery(
async (userId: string) => prisma.user.findUnique({ where: { id: userId } }),
['user'],
'user' // No persistent cache, just request memoization
);
Step 3: Implement a Tag Management System
// lib/cache/tags.ts
// Tag generators for consistency
export const CacheTags = {
// Domain-level
products: () => 'products' as const,
categories: () => 'categories' as const,
inventory: () => 'inventory' as const,
// Entity-level
product: (id: string) => `product-${id}` as const,
category: (id: string) => `category-${id}` as const,
// Relation-level
productReviews: (productId: string) => `product-${productId}-reviews` as const,
productVariants: (productId: string) => `product-${productId}-variants` as const,
categoryProducts: (categoryId: string) => `category-${categoryId}-products` as const,
} as const;
// Revalidation helpers
import { revalidateTag } from 'next/cache';
export const Revalidate = {
product(id: string) {
revalidateTag(CacheTags.product(id));
revalidateTag(CacheTags.productReviews(id));
revalidateTag(CacheTags.productVariants(id));
},
allProducts() {
revalidateTag(CacheTags.products());
},
category(id: string) {
revalidateTag(CacheTags.category(id));
revalidateTag(CacheTags.categoryProducts(id));
},
inventory(productId: string) {
revalidateTag(CacheTags.inventory());
revalidateTag(CacheTags.product(productId));
},
};
// In Server Actions:
async function updateProduct(id: string, data: ProductUpdate) {
'use server';
await db.product.update({ where: { id }, data });
Revalidate.product(id);
}
async function updateInventory(productId: string, quantity: number) {
'use server';
await db.inventory.update({ where: { productId }, data: { quantity } });
Revalidate.inventory(productId);
}
Step 4: Handle the Static/Dynamic Boundary
// Pattern: Static shell with dynamic islands
// app/products/[id]/page.tsx
import { Suspense } from 'react';
// Static: Product info (cached)
async function ProductInfo({ id }: { id: string }) {
const product = await getProduct(id); // Cached for 5 min
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
</div>
);
}
// Dynamic: Inventory (short cache)
async function InventoryStatus({ id }: { id: string }) {
const inventory = await getInventory(id); // Cached for 30s
return <span>{inventory.quantity} in stock</span>;
}
// Dynamic: User-specific (no cache)
async function AddToCartButton({ id }: { id: string }) {
const user = await getCurrentUser(); // No persistent cache
const cartItem = user ? await getCartItem(user.id, id) : null;
return (
<button>
{cartItem ? `In cart (${cartItem.quantity})` : 'Add to cart'}
</button>
);
}
// Page composition
export default async function ProductPage({ params }: { params: { id: string } }) {
const { id } = params;
return (
<div>
{/* This part can be statically rendered */}
<ProductInfo id={id} />
{/* These stream in with appropriate cache settings */}
<Suspense fallback={<Skeleton />}>
<InventoryStatus id={id} />
</Suspense>
<Suspense fallback={<Skeleton />}>
<AddToCartButton id={id} />
</Suspense>
</div>
);
}
Step 5: Set Route-Level Defaults
// app/products/[id]/page.tsx
// Force static generation where possible
export const dynamic = 'force-static';
export const revalidate = 300; // Revalidate page every 5 minutes
// Generate static pages at build time
export async function generateStaticParams() {
const products = await db.product.findMany({
select: { id: true },
where: { status: 'published' },
take: 1000, // Top 1000 products
});
return products.map((product) => ({
id: product.id,
}));
}
// app/dashboard/page.tsx
// Force dynamic for user-specific pages
export const dynamic = 'force-dynamic';
The Complete Mental Model
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS CACHING DECISION TREE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Request comes in │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Router Cache │◄─── Client-side only │
│ │ (if navigating) │ Hit? Return cached RSC payload │
│ └────────┬────────┘ │
│ │ Miss │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Full Route Cache│◄─── Static routes only │
│ │ │ Hit? Return cached HTML + RSC │
│ └────────┬────────┘ │
│ │ Miss/Dynamic │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Render Route │ │
│ │ │ │
│ │ For each fetch/query: │
│ │ ┌─────────────────────────────────────────────────────┐ │
│ │ │ │ │
│ │ │ ┌──────────────────┐ │ │
│ │ │ │ Request Memo │◄── Same request this render? │ │
│ │ │ │ (React cache) │ Return memoized result │ │
│ │ │ └────────┬─────────┘ │ │
│ │ │ │ New request │ │
│ │ │ ▼ │ │
│ │ │ ┌──────────────────┐ │ │
│ │ │ │ Data Cache │◄── fetch() with caching? │ │
│ │ │ │ (fetch cache) │ Hit? Return cached data │ │
│ │ │ └────────┬─────────┘ │ │
│ │ │ │ │ │
│ │ │ ┌──────────────────┐ │ │
│ │ │ │ unstable_cache │◄── Non-fetch with caching? │ │
│ │ │ │ │ Hit? Return cached data │ │
│ │ │ └────────┬─────────┘ │ │
│ │ │ │ Miss │ │
│ │ │ ▼ │ │
│ │ │ ┌──────────────────┐ │ │
│ │ │ │ Origin │◄── Fetch from API/DB │ │
│ │ │ │ (API/Database) │ Store in cache if enabled │ │
│ │ │ └──────────────────┘ │ │
│ │ │ │ │
│ │ └─────────────────────────────────────────────────────┘ │
│ │ │
│ └────────┬────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Return Response │ │
│ │ Update caches │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Debugging Cache Issues
Identify What's Cached
// Add cache status headers in development
// middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request: Request) {
const response = NextResponse.next();
if (process.env.NODE_ENV === 'development') {
response.headers.set('X-Cache-Debug', 'enabled');
}
return response;
}
// In your data layer
export async function getProduct(id: string) {
const start = performance.now();
const product = await getCachedProduct(id);
const duration = performance.now() - start;
// Fast response likely means cache hit
// Slow response likely means cache miss
console.log(`getProduct(${id}): ${duration.toFixed(2)}ms`);
return product;
}
Force Fresh Data
// Development: Skip all caches
// next.config.js
module.exports = {
// Disable static generation in dev
output: process.env.NODE_ENV === 'production' ? 'standalone' : undefined,
};
// Per-request: Add cache-busting
async function debugFetch(url: string) {
return fetch(`${url}?_t=${Date.now()}`, { cache: 'no-store' });
}
// Route-level: Force dynamic
export const dynamic = 'force-dynamic';
export const revalidate = 0;
Common Cache Bugs
// Bug 1: Cached data shows for wrong user
// Cause: User-specific data in shared cache
// Fix: Don't cache user-specific data, or include userId in cache key
// Bug 2: Updates don't appear
// Cause: Missing revalidation
// Fix: Call revalidateTag/revalidatePath after mutations
// Bug 3: Stale data after navigation
// Cause: Router Cache
// Fix: router.refresh() or reduce staleTimes
// Bug 4: Different data on refresh vs navigation
// Cause: Router Cache serving old data
// Fix: Align cache TTLs or use polling for volatile data
// Bug 5: Data fetched multiple times
// Cause: Different cache keys (query params, headers differ)
// Fix: Normalize fetch calls, use consistent URLs
Production Checklist
┌─────────────────────────────────────────────────────────────────────────────┐
│ CACHING STRATEGY CHECKLIST │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Data Classification │
│ □ Identified all data sources and their volatility │
│ □ Classified data as static, dynamic, or real-time │
│ □ Separated user-specific from shared data │
│ │
│ Cache Configuration │
│ □ Set appropriate revalidate times per data type │
│ □ Implemented tag-based invalidation strategy │
│ □ Configured Router Cache staleTimes for your needs │
│ │
│ Invalidation │
│ □ All mutations call appropriate revalidation │
│ □ Tag hierarchy allows surgical and broad invalidation │
│ □ Tested that updates appear correctly │
│ │
│ Performance │
│ □ Critical pages are statically generated │
│ □ Dynamic content isolated with Suspense boundaries │
│ □ Request memoization used for repeated queries │
│ │
│ Debugging │
│ □ Cache timing logs in development │
│ □ Easy way to force fresh data when needed │
│ □ Monitoring for cache hit rates in production │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The Bottom Line
Next.js caching isn't complicated once you understand the layers:
-
Request Memoization: Automatic deduplication within a single render. Use
cache()for non-fetch. -
Data Cache: Persistent fetch caching across requests. Control with
cache,revalidate, andtags. -
Full Route Cache: Complete page caching for static routes. Opt out with dynamic functions or config.
-
unstable_cache: Data Cache for non-fetch operations. Same mental model as fetch caching.
-
Router Cache: Client-side RSC caching. Invalidate with
revalidatePath/revalidateTagorrouter.refresh().
The key insight: design your caching strategy around your data's characteristics, not around which API to use. Classify your data first, then choose the appropriate caching mechanism.
Stop trial-and-error. Start with a coherent architecture.
What did you think?