Next.js Caching Deep Dive — Understand and control the four caching layers
Recipe
// Layer 1: Request Memoization — automatic dedup within a single render
// Both components call the same function, but only ONE database query executes
async function getUser(id: string) {
// React deduplicates this automatically during a single render pass
return db.user.findUnique({ where: { id } });
}
// Layer 2: Data Cache — persistent cache for fetch() results
const data = await fetch("https://api.example.com/products", {
next: { revalidate: 3600, tags: ["products"] },
});
// Layer 3: Full Route Cache — pre-rendered HTML for static routes
// Automatic for pages without dynamic functions (cookies, headers, searchParams)
// Layer 4: Router Cache — client-side cache for visited routes
// Automatic for all navigations, cached for 30s (dynamic) or 5min (static)
// Invalidation
import { revalidateTag, revalidatePath } from "next/cache";
// Targeted: invalidate all fetches tagged "products"
revalidateTag("products");
// Broad: invalidate a specific route
revalidatePath("/products");When to reach for this: When you need to control data freshness vs performance. Understanding these layers prevents stale data bugs and enables aggressive caching for frequently accessed pages.
Working Example
// ---- BEFORE: No caching strategy — every page load hits the database ----
// app/products/page.tsx
export const dynamic = "force-dynamic"; // Opts out of ALL caching
export default async function ProductsPage() {
// Hits database on EVERY request — 120ms per visit
const products = await db.product.findMany({
include: { category: true },
orderBy: { createdAt: "desc" },
});
// Same query executed AGAIN for the count
const allProducts = await db.product.findMany();
const totalCount = allProducts.length;
return (
<div>
<h1>Products ({totalCount})</h1>
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
// app/products/[id]/page.tsx
export const dynamic = "force-dynamic";
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// Hits database on every visit — even for the same product
const product = await db.product.findUnique({
where: { id },
include: { reviews: true, seller: true },
});
return <ProductDetail product={product} />;
}
// Result: 120ms per page load, no caching, database under constant load
// ---- AFTER: Layered caching strategy — sub-50ms for cached pages ----
// lib/data/products.ts — Centralized data access with caching
import { cache } from "react";
import { unstable_cache } from "next/cache";
// Layer 1: Request Memoization — dedup within a single render
// React's cache() ensures this only runs once per render pass,
// even if called from multiple Server Components
export const getProductById = cache(async (id: string) => {
return db.product.findUnique({
where: { id },
include: { reviews: true, seller: true },
});
});
// Layer 2: Data Cache — persistent cache across requests
export const getProducts = unstable_cache(
async () => {
return db.product.findMany({
include: { category: true },
orderBy: { createdAt: "desc" },
});
},
["products-list"], // Cache key
{
revalidate: 3600, // Revalidate every hour
tags: ["products"], // Tag for targeted invalidation
}
);
export const getProductCount = unstable_cache(
async () => {
return db.product.count();
},
["product-count"],
{
revalidate: 3600,
tags: ["products"],
}
);
// app/products/page.tsx — Uses cached data
export default async function ProductsPage() {
// Both use the "products" cache — fast after first request
const [products, totalCount] = await Promise.all([
getProducts(),
getProductCount(),
]);
return (
<div>
<h1>Products ({totalCount})</h1>
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
);
}
// Layer 3: Full Route Cache — pre-render product pages at build time
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { id: true } });
return products.map((p) => ({ id: p.id }));
}
// app/products/[id]/page.tsx — Statically generated + ISR
export const revalidate = 3600; // ISR: regenerate every hour
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await getProductById(id);
if (!product) notFound();
return <ProductDetail product={product} />;
}
// Invalidation: Server Action after product update
// app/actions/products.ts
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function updateProduct(id: string, data: ProductUpdateData) {
await db.product.update({ where: { id }, data });
// Invalidate the data cache for all product fetches
revalidateTag("products");
// Invalidate the specific product page route cache
revalidatePath(`/products/${id}`);
}
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } });
// Invalidate everything tagged "products"
revalidateTag("products");
// Invalidate the product listing page
revalidatePath("/products");
}What this demonstrates:
- Request memoization:
getProductByIdcalled from multiple components executes only once per render - Data cache: product list cached for 1 hour, sub-5ms on repeat requests (vs 120ms uncached)
- Full Route Cache: product pages pre-rendered at build time via
generateStaticParams - ISR: cached pages revalidate every hour automatically
- Targeted invalidation:
revalidateTag("products")invalidates all product-related caches after mutations - Database load reduced by approximately 95% for read-heavy product catalog
Deep Dive
How It Works
- Request Memoization is React's built-in deduplication. When the same
fetch()URL orcache()-wrapped function is called multiple times during a single server render, only one execution happens. The result is shared across all call sites. This is automatic and requires no configuration. - Data Cache persists
fetch()responses across requests and deployments. Whennext: { revalidate: N }is set, the cached response is served for N seconds. After N seconds, the next request triggers a background revalidation (stale-while-revalidate pattern). Useunstable_cachefor non-fetch data sources like database queries. - Full Route Cache stores the pre-rendered HTML and RSC payload for static routes. Pages without dynamic functions (
cookies(),headers(),searchParams) are statically rendered at build time. Dynamic pages are rendered on first request and cached. - Router Cache is a client-side in-memory cache that stores the RSC payload of previously visited routes. When the user navigates back to a visited page, the cached version is shown instantly. Dynamic pages cache for 30 seconds; static pages cache for 5 minutes.
revalidateTaginvalidates all cached entries (Data Cache and Full Route Cache) associated with a specific tag. This is more targeted thanrevalidatePath, which invalidates everything on a route.revalidatePathinvalidates the Full Route Cache for a specific path and triggers a re-render on the next request.
Variations
Opting out of caching per fetch:
// No caching — always fresh data
const data = await fetch("https://api.example.com/live-prices", {
cache: "no-store",
});
// Equivalent: dynamic route segment config
export const dynamic = "force-dynamic";
export const revalidate = 0;Time-based revalidation (ISR):
// Page-level revalidation
export const revalidate = 60; // Revalidate every 60 seconds
// Fetch-level revalidation
const data = await fetch(url, {
next: { revalidate: 300 }, // This specific fetch caches for 5 minutes
});On-demand revalidation in Server Actions:
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function publishPost(id: string) {
await db.post.update({
where: { id },
data: { published: true },
});
// Granular: only invalidate blog-related caches
revalidateTag("blog-posts");
revalidateTag(`post-${id}`);
// Broad: invalidate the entire blog section
revalidatePath("/blog", "layout");
}Cache debugging with headers:
// next.config.ts — expose cache status headers
const nextConfig = {
logging: {
fetches: {
fullUrl: true, // Log full fetch URLs with cache status
},
},
};
// Check response headers in DevTools:
// x-nextjs-cache: HIT — served from Full Route Cache
// x-nextjs-cache: MISS — rendered on demand, now cached
// x-nextjs-cache: STALE — served stale, revalidating in backgroundTypeScript Notes
unstable_cacheaccepts a generic:unstable_cache<Product[]>(fn, keys, opts).revalidateTagandrevalidatePathare typed to acceptstringparameters.generateStaticParamsreturn type is inferred from the route segment parameters.cache()from React preserves the wrapped function's type signature.
Gotchas
-
cookies()orheaders()opting out of caching — Callingcookies()anywhere in a route makes the entire route dynamic, disabling Full Route Cache. Fix: Movecookies()calls into the specific Server Component that needs them, or use middleware for auth checks. -
Stale data after mutations — Updating data without calling
revalidateTagorrevalidatePathleaves cached pages showing old data. Fix: Always revalidate after Server Actions that mutate data. -
unstable_cachekey collisions — Two different queries with the same cache key overwrite each other. Fix: Use descriptive, unique cache keys that include the query parameters:["products", category, sortBy]. -
Router Cache showing stale pages — The client-side Router Cache may show a stale version of a page even after server revalidation. Fix: Use
router.refresh()to force a fresh fetch from the server, or accept the 30-second staleness window. -
revalidatePathis broader than expected —revalidatePath("/products")invalidates the products listing page but not individual product pages. Fix: UserevalidatePath("/products", "layout")to invalidate the layout and all child routes, or userevalidateTagfor fine-grained control. -
Fetch-level caching in Server Components with database clients —
fetch()caching only works with thefetchAPI. Prisma, Drizzle, and other database clients bypass the Data Cache. Fix: Wrap database queries inunstable_cachefor persistent caching. -
Development mode does not cache — In
next dev, caching is disabled by default to simplify development. Fix: Test caching behavior in production builds:npm run build && npm start.
Alternatives
| Approach | Trade-off |
|---|---|
| Next.js built-in caching | Integrated; complex mental model with 4 layers |
| Redis or Upstash | External cache; more control, more infrastructure |
| CDN caching (Cloudflare, Vercel Edge) | Edge-level; cache invalidation is harder |
| SWR stale-while-revalidate | Client-side; no server cache, adds client JS |
| ISR (Incremental Static Regeneration) | Time-based; stale data within revalidation window |
| On-demand revalidation | Precise; requires explicit calls after every mutation |
| Static generation (SSG) | Build-time only; no runtime data, fastest possible |
FAQs
What are the four caching layers in Next.js and what does each cache?
- Request Memoization: Deduplicates identical calls within a single server render.
- Data Cache: Persists
fetch()responses across requests and deployments. - Full Route Cache: Stores pre-rendered HTML and RSC payload for static routes.
- Router Cache: Client-side in-memory cache of previously visited routes.
What is the difference between revalidateTag and revalidatePath?
revalidateTag("products")invalidates all cached entries (Data Cache + Full Route Cache) with that tag -- fine-grained.revalidatePath("/products")invalidates the Full Route Cache for a specific path.- Use tags for targeted invalidation; use path for broad route-level invalidation.
How does React's cache() function work for request memoization?
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
// Called in Component A and Component B during the same render:
// Only ONE database query executes; both receive the same result.When should you use unstable_cache instead of fetch with next.revalidate?
fetch()caching only works with the Fetch API.- Database clients (Prisma, Drizzle) bypass the Data Cache entirely.
- Fix: Wrap database queries in
unstable_cachefor persistent cross-request caching.
Gotcha: Why does calling cookies() make an entire route dynamic?
cookies()is a dynamic function that requires per-request data.- Calling it anywhere in a route disables Full Route Cache for that entire route.
- Fix: Move
cookies()calls into the specific Server Component that needs them, or use middleware.
What is the stale-while-revalidate pattern in the Data Cache?
- After
revalidate: Nseconds, the cached response is served (stale) while a background revalidation runs. - The next request after revalidation receives the fresh data.
- Users never wait for the revalidation -- they get instant stale data followed by fresh on the next visit.
How do you debug which caching layer is serving a response?
- Enable
logging: { fetches: { fullUrl: true } }innext.config.tsto log fetch URLs with cache status. - Check
x-nextjs-cacheresponse headers:HIT,MISS, orSTALE. HIT= Full Route Cache,MISS= rendered on demand,STALE= served stale while revalidating.
Gotcha: Why might a user still see stale data after you call revalidateTag?
- The client-side Router Cache may still hold a stale version for up to 30 seconds (dynamic) or 5 minutes (static).
- Fix: Use
router.refresh()to force a fresh fetch from the server, or accept the staleness window.
How do you type unstable_cache with a generic return type in TypeScript?
import { unstable_cache } from "next/cache";
const getProducts = unstable_cache<Product[]>(
async () => {
return db.product.findMany();
},
["products-list"],
{ revalidate: 3600, tags: ["products"] }
);
// Return type is inferred as Promise<Product[]>How does cache() from React preserve the wrapped function's TypeScript type?
cache()returns a function with the same type signature as the original.- Parameters and return type are fully preserved -- no need for explicit generics.
- IDE autocomplete and type checking work identically on the cached version.
What is the difference between export const dynamic = "force-dynamic" and cache: "no-store"?
dynamic = "force-dynamic"opts the entire route out of all caching layers.cache: "no-store"on a specificfetch()opts only that fetch out of the Data Cache.- Prefer per-fetch control for granularity; use route-level only when every fetch must be dynamic.
How does generateStaticParams interact with the Full Route Cache?
generateStaticParamspre-renders specific dynamic route pages at build time.- These pages are stored in the Full Route Cache and served instantly.
- Combined with
revalidate, they use ISR to regenerate periodically without a full rebuild.
What happens if two unstable_cache calls use the same cache key but different queries?
- They overwrite each other -- the second call's result replaces the first.
- Fix: Use descriptive, unique cache keys that include query parameters:
["products", category, sortBy].
Related
- Data Fetching Performance — Parallel fetching and waterfall elimination
- Server Component Performance — Server-side data fetching patterns
- Suspense & Streaming — Streaming cached and uncached sections
- Performance Checklist — Caching audit items