The first Next.js App Router site I shipped for a client showed yesterday's prices for six hours after they updated the CMS. The second one re-rendered every page on every request and cost four times what it should have. Both were caching mistakes, and both came from not having a clear picture of which cache was doing what. This is that picture.

The four caches, in one diagram

┌─────────────────────────────────────────────────┐ Browser │ 4. ROUTER CACHE (client, in memory) │ prefetched RSC payloads; │ cleared on hard reload / router.refresh() │ short-lived └─────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────┐ Server │ 3. FULL ROUTE CACHE (rendered HTML + RSC payload)│ static routes only; │ persists across requests until revalidated │ built at build time └─────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────┐ │ 2. DATA CACHE (fetch results / unstable_cache) │ persists across requests │ keyed by URL+options or your key; tag-aware │ and deployments └─────────────────────────────────────────────────┘ │ ┌─────────────────────────────────────────────────┐ │ 1. REQUEST MEMOISATION (per render pass) │ same fetch() in 5 components │ deduplicates identical GETs during ONE render│ = 1 network call └─────────────────────────────────────────────────┘

Most confusion comes from mixing up 2 and 3. The data cache stores the results of individual fetches. The full route cache stores the rendered output of an entire static route. A route can be dynamic (no route cache) while its fetches are still served from the data cache, which is the combination you usually want for personalised pages that show shared content.

What the defaults are now

Next.js 14 cached aggressively by default and surprised everyone. Next.js 15 flipped it: fetch defaults to no-store, GET route handlers are dynamic, and the client router cache no longer reuses page segments for dynamic routes. That means you now opt into caching explicitly, which is less magical and, in my experience, far easier to reason about.

// Not cached (default in Next 15+): hits the API on every request
const live = await fetch('https://api.example.com/prices');

// Cached in the data cache indefinitely, until revalidated
const cached = await fetch('https://api.example.com/catalog', { cache: 'force-cache' });

// Cached, and automatically revalidated in the background after 5 minutes (ISR-style)
const fresh = await fetch('https://api.example.com/posts', { next: { revalidate: 300 } });

// Cached with tags so you can purge precisely from a webhook
const post = await fetch(`https://cms.example.com/posts/${id}`, { next: { tags: ['posts', `post-${id}`] } });

Caching database calls and SDKs

fetch options do nothing for a Postgres query or a Stripe SDK call. For those, wrap the function with unstable_cache (or the "use cache" directive if you have enabled dynamicIO):

// lib/queries.ts
import { unstable_cache } from 'next/cache';
import { sql } from '@/lib/db';

export const getPublishedPosts = unstable_cache(
  async (limit: number) => sql`SELECT id, slug, title, excerpt FROM posts WHERE status = 'published' ORDER BY published_at DESC LIMIT ${limit}`,
  ['published-posts'],                              // part of the cache key
  { tags: ['posts'], revalidate: 3600 }             // tag for purging, 1h safety net
);

export const getPost = unstable_cache(
  async (slug: string) => (await sql`SELECT * FROM posts WHERE slug = ${slug}`)[0] ?? null,
  ['post'],
  { tags: ['posts'] }                               // per-post tag added below
);

One gotcha: arguments become part of the key automatically, but tags do not vary by argument in that call. For per-post tags, generate the wrapper per slug:

export function getPostCached(slug: string) {
  return unstable_cache(
    () => getPostUncached(slug),
    ['post', slug],
    { tags: ['posts', `post-${slug}`] }
  )();
}

Invalidation: tags beat timers

A one-hour revalidate means content is stale for up to an hour. That was the six-hour bug (there were three nested layers each set to two hours). The fix is on-demand revalidation: the CMS calls a webhook the moment something changes, and you purge exactly the tags affected.

// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';

export async function POST(req: NextRequest) {
  const sig = req.headers.get('x-webhook-secret') ?? '';
  const expected = process.env.REVALIDATE_SECRET ?? '';
  if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }

  const { type, slug } = await req.json();       // sent by the CMS (WordPress hook, Sanity, etc.)
  if (type === 'post' && slug) {
    revalidateTag(`post-${slug}`);              // the single post
    revalidateTag('posts');                     // lists that include it
    revalidatePath('/sitemap.xml');
  }
  return NextResponse.json({ ok: true, revalidated: Date.now() });
}

On the WordPress side this is a save_post hook doing one wp_remote_post(); the full setup, including preview mode, is in my headless WordPress architecture guide. The measured update latency on that site went from "up to two hours" to about 300 ms.

What makes a route dynamic (and how to contain it)

Any of these in a page or a component it renders opts the whole route out of the full route cache:

  • cookies(), headers(), searchParams (awaited in Next 15+)
  • An uncached fetch (the default), or connection() / noStore()
  • export const dynamic = 'force-dynamic'

The trap: one tiny "Hello, {user}" in the header makes every page dynamic. The fix is to keep the dynamic read in a small component and wrap it in Suspense, so the static shell is cached and only the greeting streams in:

// components/UserGreeting.tsx  (server, dynamic)
import { cookies } from 'next/headers';
export async function UserGreeting() {
  const name = (await cookies()).get('display_name')?.value;
  return <span>{name ? `Hi, ${name}` : 'Sign in'}</span>;
}

// app/layout.tsx  (static shell, streams the dynamic hole)
import { Suspense } from 'react';
export default function RootLayout({ children }) {
  return (
    <html><body>
      <header>
        <Logo />
        <Suspense fallback={<span>…</span>}><UserGreeting /></Suspense>
      </header>
      {children}
    </body></html>
  );
}

With Partial Prerendering enabled this shell is served from the edge as static HTML and the greeting streams from the server; without it you still get a static route with a dynamic hole streamed in. Either way, the product pages beneath stay in the full route cache.

Decision table

ContentStrategyCode
Marketing pages, docsStatic, rebuild on deployDefault with cached fetches; generateStaticParams
Blog / CMS contentStatic + on-demand revalidationtags + webhook → revalidateTag; revalidate: 86400 as a safety net
Product catalogue with pricesStatic shell, short-TTL price fetchPage cached; prices via revalidate: 60 or a client island
Personalised dashboardDynamic route, cached datacookies() in page; shared data via unstable_cache with tags
Search resultsDynamic, no cache (or short)Reads searchParams; cache the expensive part (e.g. query embedding) in Redis
Real-time dataNo server cachecache: 'no-store' + client polling or streaming

Debugging in three commands

  1. next build prints a route table: means static (full route cache), ƒ means dynamic. If a route you expected to be static is ƒ, something above is reading request data or doing an uncached fetch.
  2. Set logging: { fetches: { fullUrl: true } } in next.config.js and dev logs will show each fetch as (cache: HIT) or (cache: MISS).
  3. Check response headers in production: x-nextjs-cache: HIT | MISS | STALE on the page, and on Vercel x-vercel-cache for the CDN layer.
The router cache is the one users notice

After a Server Action, call revalidatePath or revalidateTag inside the action; it clears the server caches and the client router cache for that path in one go. Calling router.refresh() from the client is the fallback when the mutation happened elsewhere (another tab, a webhook). If a user says "I saved but the list didn't update", it is almost always this.

Once these four layers are in your head, the App Router stops feeling unpredictable and starts feeling like a CDN you control from code. Pair it with the Server Components mental model and you have the whole rendering story. For the layer below Next.js, when you own the server, see Redis caching strategies and Nginx tuning.