React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

nextjscachingrequest-memoizationdata-cacheroute-cacherouter-cacherevalidationisr

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: getProductById called 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 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.
  • Data Cache persists 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.
  • 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.
  • 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.

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 background

TypeScript Notes

  • unstable_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.

Gotchas

  • 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 expectedrevalidatePath("/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 clientsfetch() 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.

Alternatives

ApproachTrade-off
Next.js built-in cachingIntegrated; complex mental model with 4 layers
Redis or UpstashExternal cache; more control, more infrastructure
CDN caching (Cloudflare, Vercel Edge)Edge-level; cache invalidation is harder
SWR stale-while-revalidateClient-side; no server cache, adds client JS
ISR (Incremental Static Regeneration)Time-based; stale data within revalidation window
On-demand revalidationPrecise; 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_cache for 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: N seconds, 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 } } in next.config.ts to log fetch URLs with cache status.
  • Check x-nextjs-cache response headers: HIT, MISS, or STALE.
  • 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 specific fetch() 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?
  • generateStaticParams pre-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].