Ch. 24 · Next.js

Next.js Caching and Revalidation: The Four Layers Explained

Request memoization, the Data Cache, the Full Route Cache and the Router Cache in Next.js 15, and how to debug a page that shows stale data.

~8 min readadvancedupdated Oct 6, 2026

“A user changes their display name, the save succeeds, but the profile page still shows the old one. Walk me through how you would debug it.” That is how caching usually comes up in a senior Next.js interview. A broader version is “Explain how caching works in the App Router”, and the follow-up is almost always “What changed in Next.js 15?”

Interviewers ask because caching is where Next.js apps misbehave in production. Stale pages, a backend overwhelmed after an upgrade, and data that is correct on one server but not another all trace back to not knowing which cache is involved. A clear mental model of the layers, and a methodical way to walk through them, is what separates a strong answer.

Before you start

You should know the difference between static and dynamic rendering in the App Router, how fetch works in Server Components, and what Server Actions are. Basic HTTP caching ideas, such as stale-while-revalidate, help. Examples use Next.js 15.5; Next.js 16 replaces much of this configuration with Cache Components, which is noted where relevant.

The short answer

Next.js 15 has four caches. Request memoization deduplicates identical GET fetches within one server render. The Data Cache stores fetch results across requests and deployments when you opt in with cache: 'force-cache' or next.revalidate. The Full Route Cache stores the HTML and RSC payload of static routes. The Router Cache keeps visited and prefetched segments in browser memory. Since Next.js 15, fetch and GET Route Handlers are uncached by default and dynamic pages are not reused from the Router Cache, so caching is mostly opt-in. You invalidate with revalidatePath or revalidateTag, called from the Server Action or Route Handler that changed the data.

How it works

Follow one navigation from the browser inwards.

Layer Lives in Holds Lasts Cleared by
Router Cache browser memory RSC payload per segment session; dynamic pages 0 s, static 300 s by default router.refresh(), revalidation or cookies().set in an action
Full Route Cache server HTML and RSC payload of static routes until revalidated or redeployed revalidatePath, revalidateTag, time window
Request memoization server, one render return values of identical fetches one render automatic
Data Cache server fetch responses that opted in across requests and deploys time window, revalidateTag, revalidatePath

When the user clicks a Link, the router first checks the Router Cache. In Next.js 15, page segments of dynamic routes have a stale time of 0 seconds (staleTimes.dynamic: 0), so a fresh request is made; back and forward navigation and shared layouts still reuse cached segments. On the server, a static route is answered from the Full Route Cache without rendering. A dynamic route renders, and during that render, identical fetches are memoized. Each fetch then checks the Data Cache if it opted in.

// Opting in to the Data Cache in Next.js 15
await fetch(url);                                         // not stored (default)
await fetch(url, { cache: 'force-cache' });               // stored until invalidated
await fetch(url, { next: { revalidate: 300 } });          // stored, stale after 5 minutes
await fetch(url, { next: { tags: ['user-42'] } });        // tagged for revalidateTag
TSX

Two details trip people up. First, “not stored in the Data Cache” does not mean “rendered per request”. A route with a plain fetch and no request APIs is still prerendered at build time, so the response is frozen into the Full Route Cache. Second, memoization works even with cache: 'no-store': two components calling the same URL in one render cause one backend request. For database calls, which are not fetch, use React cache() for per-request dedupe and unstable_cache for cross-request caching:

import { cache } from 'react';
import { unstable_cache } from 'next/cache';

export const getUser = cache(async (id: string) => db.user.findUnique({ where: { id } }));

export const getPlans = unstable_cache(
  async () => db.plan.findMany(),
  ['plans'],                                  // cache key parts
  { tags: ['plans'], revalidate: 3600 },
);
TypeScript

Step-by-step walkthrough

Use this order when data looks stale. It moves from the cheapest check to the most specific.

Step 1: Find out whether the route is static

Run next build and look up the route. ○ or ● means the page is served from the Full Route Cache, so its data is only as fresh as the last build or revalidation. ƒ means it renders per request. If a page you thought was dynamic is static, decide whether it should be dynamic (cache: 'no-store', connection()), or static with invalidation, which is usually better.

Step 2: Check every fetch and data function on the route

List the fetches and their options. A force-cache or revalidate fetch will keep returning stored data even on a dynamic route. Also check layouts: they are part of the route, and the lowest revalidate value on the route wins.

Step 3: Invalidate from the mutation

The code that changes the data should invalidate it. Tag the read, then revalidate the tag in the write.

// lib/users.ts
export async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`, {
    next: { tags: [`user-${id}`] },
    cache: 'force-cache',
  });
  return res.json();
}

// app/profile/actions.ts
'use server';
import { revalidateTag } from 'next/cache';

export async function updateName(formData: FormData) {
  const session = await requireSession();
  await api.updateUser(session.userId, { name: String(formData.get('name')) });
  revalidateTag(`user-${session.userId}`);
}
TSX

Tags are better than paths when the same data appears on several pages, such as a name in the header and on the profile. revalidatePath('/profile') is fine when one route owns the data.

Step 4: Refresh the client

When the mutation runs in a Server Action that calls revalidatePath or revalidateTag, Next sends fresh UI back in the same response and clears the Router Cache, so nothing else is needed. When the mutation goes through a client fetch to a Route Handler, the browser does not know anything changed. Call router.refresh() after the request succeeds to re-request the current route.

Worked scenario

The profile page reads the user with getUser (tagged, force-cache). The edit form is a Client Component that sends PUT /api/profile, and the Route Handler updates the database and returns 200. After saving, the page still shows the old name, even after a hard reload.

Walk the layers. The hard reload rules out the Router Cache. The build output shows /profile as ƒ because it reads the session cookie, so the Full Route Cache is not involved. The fetch, however, uses force-cache, and nothing invalidates it, so every render reads the old entry from the Data Cache.

// app/api/profile/route.ts: the fix
import { revalidateTag } from 'next/cache';

export async function PUT(req: Request) {
  const session = await requireSession();
  const { name } = await req.json();
  await api.updateUser(session.userId, { name });
  revalidateTag(`user-${session.userId}`);       // purge the Data Cache entry
  return Response.json({ ok: true });
}
TypeScript

On the client, call router.refresh() after a successful response so the open page re-renders without a reload. The cleaner long-term fix is to replace the Route Handler with a Server Action, which revalidates and returns new UI in one round trip.

Common mistake

  • “Next.js 15 removed caching.” It changed defaults. The Data Cache, Full Route Cache and Router Cache still exist and are opt-in or automatic depending on the layer.
  • “router.refresh() clears the cache.” It clears the Router Cache for the route and re-renders on the server, but cached fetches return the same stored data.
  • Revalidating in the wrong place. revalidateTag must run on the server after the write: in a Server Action or Route Handler, not in a Client Component.
  • Expecting memoization across requests. It lasts one render. Shared caching needs the Data Cache or unstable_cache.
  • Forgetting multiple instances. Self-hosted on several containers, each has its own Data Cache unless you configure a shared cacheHandler.

Verify the behavior

Build a page at /tagged that renders `tagged ${now}` from a tagged force-cache fetch to a local test API returning Date.now(), plus a POST Route Handler at /api/reval that calls revalidateTag('user-1'). Then watch the timestamp.

npx next build && npx next start -p 3000 &
t() { curl -s localhost:3000/tagged | grep -o 'tagged [0-9]*'; }
t                                       # tagged 1791297254397
t                                       # tagged 1791297254397  (Data Cache hit)
curl -s -X POST localhost:3000/api/reval   # {"ok":true}
t                                       # tagged 1791297260388  (refetched after revalidateTag)
Terminal

To prove memoization, render two components that call the same no-store URL and count hits on the test API: one request per page view.

Follow-up questions

What does export const dynamic = 'force-dynamic' do to caching? It renders the route per request and treats fetches as no-store by default, unless a fetch explicitly opts into caching.

How do you change the Router Cache stale times? With experimental.staleTimes in next.config, for example { dynamic: 30 } to reuse dynamic pages for 30 seconds during client navigation.

What changes in Next.js 16? Cache Components introduce the 'use cache' directive with cacheLife and cacheTag for caching components and functions explicitly. In Next.js 15 that directive is experimental.

Is the Data Cache shared between deployments? Yes on Vercel, and on a self-hosted server it persists in .next/cache unless you redeploy into a fresh container.

Interview exercise

An e-commerce home page shows “Top deals” from a pricing API that changes every few minutes, and a header with the signed-in user’s cart count. After upgrading from Next.js 14 to 15, the pricing API team reports ten times more traffic. Explain the likely cause and your fix.

Answer and reasoning

In Next.js 14, fetch was cached by default, so the deals request was served from the Data Cache across visitors. Next.js 15 made fetch uncached by default, and because the header reads the cart cookie, the route is dynamic, so every page view now calls the pricing API. The fix is to make the intent explicit: fetch deals with next: { revalidate: 120, tags: ['deals'] } so all visitors share one cached response refreshed at most every two minutes, and call revalidateTag('deals') from the pricing admin when prices change. The cart count stays per request. Optionally move the cart count into a small streamed component so it does not delay the rest of the page. Then confirm with API logs that traffic is back to roughly one request per revalidation window per instance.

Continue learning

Practise more caching questions in the Next.js interview questions and the Next.js MCQs. Read SSR vs SSG vs ISR in Next.js for how routes become static, and Next.js Server Actions for mutations that revalidate. The authoritative description of each layer is the Next.js 15 caching guide.

More in Next.js

esc