Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
// 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.
// ---- 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:
getProductById called from multiple components executes only once per rendergenerateStaticParamsrevalidateTag("products") invalidates all product-related caches after mutationsfetch() URL or cache()-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.fetch() responses across requests and deployments. When next: { 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). Use unstable_cache for non-fetch data sources like database queries.cookies(), headers(), searchParams) are statically rendered at build time. Dynamic pages are rendered on first request and cached.revalidateTag invalidates all cached entries (Data Cache and Full Route Cache) associated with a specific tag. This is more targeted than revalidatePath, which invalidates everything on a route.revalidatePath invalidates the Full Route Cache for a specific path and triggers a re-render on the next request.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 backgroundunstable_cache accepts a generic: unstable_cache<Product[]>(fn, keys, opts).revalidateTag and revalidatePath are typed to accept string parameters.generateStaticParams return type is inferred from the route segment parameters.cache() from React preserves the wrapped function's type signature.cookies() or headers() opting out of caching - Calling cookies() anywhere in a route makes the entire route dynamic, disabling Full Route Cache. Fix: Move cookies() calls into the specific Server Component that needs them, or use middleware for auth checks.
Stale data after mutations - Updating data without calling revalidateTag or revalidatePath leaves cached pages showing old data. Fix: Always revalidate after Server Actions that mutate data.
unstable_cache key 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.
revalidatePath is broader than expected - revalidatePath("/products") invalidates the products listing page but not individual product pages. Fix: Use revalidatePath("/products", "layout") to invalidate the layout and all child routes, or use revalidateTag for fine-grained control.
Fetch-level caching in Server Components with database clients - fetch() caching only works with the fetch API. Prisma, Drizzle, and other database clients bypass the Data Cache. Fix: Wrap database queries in unstable_cache for 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.
| 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 |
fetch() responses across requests and deployments.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.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.fetch() caching only works with the Fetch API.unstable_cache for persistent cross-request caching.cookies() is a dynamic function that requires per-request data.cookies() calls into the specific Server Component that needs them, or use middleware.revalidate: N seconds, the cached response is served (stale) while a background revalidation runs.logging: { fetches: { fullUrl: true } } in next.config.ts to log fetch URLs with cache status.x-nextjs-cache response headers: HIT, MISS, or STALE.HIT = Full Route Cache, MISS = rendered on demand, STALE = served stale while revalidating.router.refresh() to force a fresh fetch from the server, or accept the staleness window.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[]>cache() returns a function with the same type signature as the original.dynamic = "force-dynamic" opts the entire route out of all caching layers.cache: "no-store" on a specific fetch() opts only that fetch out of the Data Cache.generateStaticParams pre-renders specific dynamic route pages at build time.revalidate, they use ISR to regenerate periodically without a full rebuild.["products", category, sortBy].Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥