Skip to content

3.4 — The Next.js caching layers

Module 3 · Lesson 4 · 🔴 Advanced · ~45 min

What you'll learn

  • All five caching layers, what each one holds, and how to invalidate each
  • The version matrix — defaults changed meaningfully across Next.js 14 → 15 → 16
  • A runtime probe that tells you what your app actually does
  • Cache design for a commerce catalog, and the failure modes that leak data between users

⚠️ Verify against your version. Caching defaults are the most version‑sensitive area of Next.js. This lesson gives you the model and a probe to establish ground truth in your own app. Never reason about caching from memory — including this document's.


The five layers

┌──────────────────────────────────────────────────────────────────────┐
│ 5. CDN / edge cache            HTML + assets, keyed by URL           │
│    TTL: s-maxage / SWR         Invalidate: purge / surrogate keys    │
├──────────────────────────────────────────────────────────────────────┤
│ 4. Full Route Cache (server)   Prerendered HTML + RSC payload        │
│    TTL: `revalidate`           Invalidate: revalidatePath / redeploy │
├──────────────────────────────────────────────────────────────────────┤
│ 3. Data Cache (server)         fetch() results, `use cache` values   │
│    TTL: revalidate / cacheLife Invalidate: revalidateTag / cacheTag  │
├──────────────────────────────────────────────────────────────────────┤
│ 2. Request Memoization         Dedupes identical calls WITHIN one    │
│    Lifetime: one render pass   render. Nothing to invalidate.        │
├──────────────────────────────────────────────────────────────────────┤
│ 1. Router Cache (client)       RSC payloads for visited routes       │
│    Lifetime: in-memory, short  Invalidate: router.refresh(), nav     │
└──────────────────────────────────────────────────────────────────────┘

A request travels 5 → 4 → 3, and a client navigation checks 1 first. Understanding which layer served a response is the core debugging skill for TTFB problems.


Layer 2 — Request Memoization

Deduplicates identical calls within a single render pass. If five components each call getProduct('abc'), the fetch happens once.

fetch gets this automatically (keyed on URL + options). For anything else — a database query, a gRPC call, an SDK — wrap it in React's cache():

// lib/data/product.ts
import { cache } from 'react';
import 'server-only';

// Memoized per render pass. Call it from the page, the metadata function,
// and three components — it executes once.
export const getProduct = cache(async (slug: string): Promise<Product | null> => {
  const row = await db.product.findUnique({ where: { slug } });
  return row ? toProduct(row) : null;
});
// Both of these run during the same render — one DB query total
export async function generateMetadata({ params }) {
  const { slug } = await params;
  const product = await getProduct(slug);
  return { title: product?.name };
}

export default async function Page({ params }) {
  const { slug } = await params;
  const product = await getProduct(slug);   // memoized hit
  return /* … */;
}

This is free and you should do it for every data accessor. It's the cheapest fix for the "why are we hitting the database six times per render" problem.

Caveats: memoization is per‑request, so it doesn't help across users. Arguments must be stable — cache() keys on argument identity, so passing a fresh object literal each time defeats it.

// ❌ New object every call → never a memo hit
const getProducts = cache(async (opts: { ids: string[] }) => { /* … */ });
getProducts({ ids: ['a', 'b'] });

// ✅ Primitive arguments
const getProducts = cache(async (idsCsv: string) => { /* … */ });
getProducts('a,b');

Layer 3 — The Data Cache

Persists fetched data across requests and users, and (on most deployments) across deploys. This is where catalog data should live.

// Explicit caching with a tag — the pattern to standardize on
async function getProduct(id: string) {
  const res = await fetch(`${API}/products/${id}`, {
    next: {
      revalidate: 3600,                       // refresh at most hourly
      tags: [`product-${id}`, 'products'],    // targeted invalidation
    },
  });
  if (!res.ok) throw new Error(`Product fetch failed: ${res.status}`);
  return res.json();
}

// Explicitly uncached — real-time data
async function getInventory(id: string) {
  const res = await fetch(`${API}/inventory/${id}`, { cache: 'no-store' });
  return res.json();
}

For non‑fetch data sources, use unstable_cache (or use cache on newer versions):

import { unstable_cache } from 'next/cache';

export const getCategoryTree = unstable_cache(
  async (marketId: string) => db.category.findMany({ where: { marketId } }),
  ['category-tree'],                       // key prefix; arguments are appended
  { revalidate: 3600, tags: ['categories'] },
);

On Next.js 15.x (experimental) and 16 (cacheComponents), the use cache directive is the successor and is much nicer to read:

// Requires the relevant experimental flag / Next.js 16 Cache Components.
// Check your version's docs before adopting.
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache';

async function getProduct(id: string) {
  'use cache';
  cacheLife('hours');            // named profile: seconds/minutes/hours/days/weeks/max
  cacheTag(`product-${id}`);
  return db.product.findUnique({ where: { id } });
}

Invalidation

// app/api/webhooks/catalog/route.ts
// Called by the PIM when a product changes.
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';

export async function POST(req: NextRequest) {
  const signature = req.headers.get('x-webhook-signature') ?? '';
  const body = await req.text();
  if (!verifySignature(body, signature)) {
    return new NextResponse('Invalid signature', { status: 401 });
  }

  const { type, productId, categoryId } = JSON.parse(body);

  switch (type) {
    case 'product.updated':
    case 'product.price_changed':
      revalidateTag(`product-${productId}`);
      // The product also appears on category pages
      if (categoryId) revalidateTag(`category-${categoryId}`);
      break;
    case 'category.updated':
      revalidateTag(`category-${categoryId}`);
      break;
    case 'catalog.bulk_import':
      // Deliberately coarse — a bulk import touches everything
      revalidateTag('products');
      break;
  }

  return NextResponse.json({ revalidated: true, now: Date.now() });
}

Tag design is the skill here. Tag hierarchy for a commerce catalog:

product-{id}        one SKU changed
variant-{id}        one variant (size/color) changed
category-{id}       category membership or ordering changed
brand-{id}          brand-level change
market-{code}       market-wide pricing change
products            nuclear option — bulk import only

Rules: - Tag at write time with everything that could invalidate it. Cheap to add, impossible to retrofit. - Never use one global tag for everything. revalidateTag('all') on every price change means you have no cache. - Price changes are the high‑frequency event. If prices change every few minutes for thousands of SKUs, don't cache price in the Data Cache at all — stream it from a dynamic hole (3.6).


Layer 4 — The Full Route Cache

Stores the rendered HTML and RSC payload for statically‑rendered routes. Populated at build time (generateStaticParams) or on first request (dynamicParams), refreshed by revalidate or revalidatePath.

export const revalidate = 300;    // route-level ISR window
// On-demand: purge a specific rendered page
revalidatePath('/p/wool-overshirt-navy');
revalidatePath('/c/[slug]', 'page');    // all category pages (the dynamic segment form)
revalidatePath('/', 'layout');          // everything under the root layout — expensive

A route is only in the Full Route Cache if it renders statically. Any dynamic API (cookies(), headers(), uncached fetch) opts the route out — see 3.1.

Self‑hosted note: by default the Full Route Cache is written to the filesystem of each instance. With 60–400 Kubernetes pods, that's 400 independent caches: your hit ratio is 1/N, and revalidateTag from one pod doesn't reach the others. You need a shared cache handler:

// cache-handler.mjs — shared ISR cache backed by Redis
// next.config.ts: { cacheHandler: require.resolve('./cache-handler.mjs'), cacheMaxMemorySize: 0 }
import { createClient } from 'redis';

const client = createClient({ url: process.env.REDIS_URL });
await client.connect();

export default class RedisCacheHandler {
  async get(key) {
    const raw = await client.get(`next:${key}`);
    return raw ? JSON.parse(raw) : null;
  }

  async set(key, data, ctx) {
    const ttl = ctx?.revalidate ? ctx.revalidate * 2 : 60 * 60;
    await client.set(`next:${key}`, JSON.stringify({ value: data, lastModified: Date.now() }), {
      EX: ttl,
    });
    // Maintain tag → key sets so revalidateTag can fan out across all pods
    for (const tag of ctx?.tags ?? []) {
      await client.sAdd(`next:tag:${tag}`, `next:${key}`);
    }
  }

  async revalidateTag(tags) {
    for (const tag of [tags].flat()) {
      const keys = await client.sMembers(`next:tag:${tag}`);
      if (keys.length) await client.del(keys);
      await client.del(`next:tag:${tag}`);
    }
  }
}

This single change took Aurora's self‑hosted ISR hit ratio from 11% to 96%. If you self‑host Next.js on more than one instance and haven't configured a shared cache handler, this is probably your biggest TTFB win and it's half a day of work.


Layer 1 — The client Router Cache

Holds RSC payloads for routes the user has visited or prefetched, in memory, for the duration of the session. It makes back/forward navigation instant — and it's the reason a user can see stale data after an update.

// next.config.ts — control how long segments stay fresh (Next.js 15+)
const config: NextConfig = {
  experimental: {
    staleTimes: {
      dynamic: 30,    // seconds a dynamic page segment is reused without a refetch
      static: 180,    // seconds a static page segment is reused
    },
  },
};

Defaults here changed in Next.js 15 (page segments default to 0 staleness, so navigating back refetches). Raising dynamic makes navigation feel instant but risks showing stale prices — a real commerce trade‑off, not a free win.

Forcing a refresh after a mutation:

'use client';
import { useRouter } from 'next/navigation';

export function AddToCartButton({ productId }: { productId: string }) {
  const router = useRouter();
  return (
    <button
      onClick={async () => {
        await addToCart(productId);
        router.refresh();   // refetch the current route's RSC payload; keeps client state
      }}
    >
      Add to bag
    </button>
  );
}

With Server Actions, revalidatePath/revalidateTag inside the action handles this for you — prefer that (5.6).


The version matrix

Behavior Next.js 14 Next.js 15 Next.js 16
fetch() default Cached (force-cache) Uncached (no-store) Uncached; use cache is the explicit model
GET Route Handlers Cached by default Uncached by default Uncached
Client Router Cache (page segments) 30 s dynamic / 5 min static 0 by default, configurable via staleTimes Configurable
use cache directive Experimental (dynamicIO / useCache) Cache Components (cacheComponents)
PPR Experimental Experimental (ppr) Available via Cache Components model
Default bundler Webpack Webpack (Turbopack opt‑in) Turbopack default
Middleware file middleware.ts middleware.ts proxy.ts (renamed)

The upgrade that bites everyone: 14 → 15. Fetches that were silently cached become uncached, so TTFB and origin load jump on upgrade day with no code change. Before upgrading, audit every fetch in server code and make its intent explicit:

# Find fetches with no explicit cache intent — each is a 14→15 behavior change
rg -n --multiline 'fetch\((?:[^)]|\n)*?\)' app lib \
  | rg -v "cache:|next:\s*\{" | head -50

Then make every one explicit. Explicit is correct in every version and immune to the next default change:

await fetch(url, { cache: 'force-cache', next: { revalidate: 3600, tags: ['x'] } }); // cached
await fetch(url, { cache: 'no-store' });                                             // not cached

The probe: find out what your app actually does

Don't trust documentation, including this page. Measure.

// app/debug/cache-probe/page.tsx
// Protect behind an env check or auth. Remove before it becomes a permanent fixture.
export const dynamic = 'force-dynamic';

async function probe(label: string, fn: () => Promise<unknown>) {
  const t0 = performance.now();
  await fn();
  return { label, ms: Math.round(performance.now() - t0) };
}

export default async function CacheProbe() {
  if (process.env.NODE_ENV === 'production' && !process.env.ENABLE_CACHE_PROBE) {
    return <p>Disabled.</p>;
  }

  const results = await Promise.all([
    probe('fetch default',      () => fetch(`${API}/health`).then((r) => r.json())),
    probe('fetch force-cache',  () => fetch(`${API}/health`, { cache: 'force-cache' }).then((r) => r.json())),
    probe('fetch no-store',     () => fetch(`${API}/health`, { cache: 'no-store' }).then((r) => r.json())),
  ]);

  return (
    <table>
      <tbody>
        {results.map((r) => (
          <tr key={r.label}><td>{r.label}</td><td>{r.ms} ms</td></tr>
        ))}
      </tbody>
    </table>
  );
}

Reload several times. A call that stays at 0–2 ms is cached; one that stays at network latency isn't. More precisely, use the built‑in cache logging:

// next.config.ts
const config: NextConfig = {
  logging: {
    fetches: { fullUrl: true, hmrRefreshes: true },
  },
};
GET /p/wool-overshirt-navy 200 in 45ms
 │ GET https://api.aurora.com/products/1234 200 in 2ms (cache hit)
 │ GET https://api.aurora.com/inventory/1234 200 in 138ms (cache skip)
 │  │  Cache missed reason: (cache: no-store)

That last line is the answer to most "why is this slow" questions. Turn it on in development and read it after any caching change.

In production, verify with headers:

curl -sI https://www.auroramarket.com/p/wool-overshirt-navy \
  | grep -iE 'x-nextjs-cache|x-vercel-cache|age|cache-control|x-cache'

Cache design for Aurora's catalog

Data Layer TTL Invalidation Why
Product content (name, copy, images, specs) Data Cache 24 h product-{id} on PIM webhook Changes rarely; big payload
Category tree Data Cache 1 h categories on merch publish Small, shared by all pages
Base price Data Cache 5 min product-{id} on price webhook Changes a few times/day
Promotional price / segment price None Per‑user; must be a dynamic hole
Inventory None (or 30 s) Oversell risk; correctness beats speed
Reviews summary (count, avg) Data Cache 1 h product-{id} on new review Tolerant of staleness
CMS blocks Data Cache 10 min cms-{id} on publish Merchandisers expect fast publish
Search results CDN, popular queries only 5 min Time‑based Long tail is uncacheable
Recommendations Data Cache keyed by product 30 min Time‑based Personalization happens client‑side on top
Cart Never Per‑user, mutable
User/session Never Per‑user

The rule that prevents the catastrophic bug

Never cache a response that contains data derived from cookies(), a session, or a user ID — at any layer. The failure mode is one customer seeing another customer's cart, address, or order history. It has happened to real retailers and it is a reportable data breach, not a bug.

Enforce it mechanically, not by review:

// lib/data/guard.ts
import 'server-only';

/**
 * Wrap any function whose result is cached. Throws in development/CI if the
 * result contains fields that must never be shared between users.
 */
const FORBIDDEN_KEYS = ['email', 'sessionId', 'userId', 'customerId', 'addressLine1', 'phone'];

export function assertNotPersonalized<T>(value: T, context: string): T {
  if (process.env.NODE_ENV === 'production') return value;
  const json = JSON.stringify(value);
  for (const key of FORBIDDEN_KEYS) {
    if (json.includes(`"${key}"`)) {
      throw new Error(
        `[cache-guard] ${context} returned a personalized field "${key}" but is cached. ` +
        `Move it to a dynamic hole.`,
      );
    }
  }
  return value;
}

Pair it with an integration test that requests a cacheable page as user A, then as user B, and asserts no A‑specific string appears in B's HTML. Run it in CI on every PR that touches caching.


Common mistakes

Mistake Cost
Assuming Next.js 14 caching defaults on 15+ Silent TTFB regression on upgrade
Self‑hosting on N pods without a shared cache handler Hit ratio ≈ 1/N; revalidateTag doesn't propagate
One global cache tag Every write invalidates everything
Caching anything derived from cookies 🚨 Data leak between users
Not using cache() for DB accessors Duplicate queries per render
revalidatePath('/', 'layout') on every write Purges the whole site
Unsigned revalidation webhooks Anyone can DoS your origin by purging in a loop
Caching inventory Overselling
Trusting docs over measurement The defaults changed; your app is the ground truth

Lab 3.4 — Map your cache

  1. Turn on logging.fetches and load your three main page types in dev. Record every fetch: URL, duration, cache hit/skip, and the skip reason.
  2. Build the table from "Cache design for Aurora's catalog" for your data, with a decision and a reason for each row.
  3. If you self‑host: check whether a shared cacheHandler is configured. If not, measure your ISR hit ratio (x-nextjs-cache header across many requests) — expect it to be terrible.
  4. Audit tags: rg -n "revalidateTag|revalidatePath|tags:\s*\[" app lib. Is there a hierarchy, or one global tag?
  5. Write the cross‑user leak test. Run it in CI.
  6. Verify production cache headers on every route type.

Checklist

  • Every fetch in server code has explicit cache intent
  • All data accessors wrapped in cache()
  • Tag hierarchy designed; no single global tag
  • Revalidation webhooks signature‑verified and rate‑limited
  • Shared cache handler configured if self‑hosting multi‑instance
  • Nothing personalized is cached, enforced by a guard + a CI test
  • Cache behavior verified by measurement in dev and by headers in production
  • Version‑specific defaults confirmed for your exact Next.js minor version

Next: 3.5 ISR at catalog scale