Next.js caching in the App Router: what it does and when to turn it off

By Yuki Tanaka · 20 July 2026154 views
Next.js caching in the App Router: what it does and when to turn it off

A developer updates a blog post title in the CMS. She refreshes the Next.js site. The title is still old. She waits five minutes. Still old. She redeploys. Still old. She clears her browser cache. Still old. She opens an incognito tab — old title. Her colleague opens the page on their machine — old title. She has no idea which cache is serving the stale content.

Next.js App Router has four distinct caching layers. Without knowing which one holds the stale data, the correct fix is not obvious — and the wrong fix (clearing the wrong cache or disabling caching entirely) creates performance problems without solving the freshness problem.

Why four caches?

Each cache layer exists to solve a different performance problem at a different scope:

  1. Request Memoization prevents duplicate network calls within a single render
  2. Data Cache prevents repeated upstream API calls across requests from different users
  3. Full Route Cache prevents re-rendering static pages on every request
  4. Router Cache prevents round-trips to the server on client-side navigation

These layers work together. A page served fast to 10,000 simultaneous users is typically benefiting from the Full Route Cache (which serves pre-rendered HTML) and the Data Cache (which serves cached API responses when the full route is invalidated).

Cache 1: Request Memoization (per-render, in-memory)

During a single server render, fetch() calls with the same URL and options are deduplicated. The second call to fetch('https://api.example.com/user/1') returns the cached result from the first call, without making a second HTTP request.

Scope: One server render
Duration: Cleared after each request completes
Opt out: Not needed — this only prevents duplicate network calls within a single render

// Both of these fetch the same data — only ONE network request is made
async function UserName({ userId }: { userId: string }) {
  const user = await fetch(`/api/users/${userId}`).then(r => r.json());
  return <span>{user.name}</span>;
}

async function UserAvatar({ userId }: { userId: string }) {
  const user = await fetch(`/api/users/${userId}`).then(r => r.json());
  return <img src={user.avatarUrl} alt={user.name} />;
}

// If both are rendered in one request, only one HTTP call is made to /api/users/123

Request memoization is automatic and transparent. It enables the pattern of co-locating data fetching in each component without worrying about duplicate requests when multiple components need the same data.

For non-fetch data sources (ORMs, database clients, SDK calls), use React's cache function to get the same deduplication:

import { cache } from 'react';
import { db } from '@/lib/database';

// Memoized for the duration of one server request
export const getUser = cache(async (userId: string) => {
  return db.users.findUnique({ where: { id: userId } });
});

// Calling getUser('123') multiple times in one render = one database query

The cache function is particularly valuable for database calls that are expensive to repeat. A layout component and a page component can both call getUser() — with cache, only one query hits the database.

Cache 2: Data Cache (persistent, disk-backed)

When a Server Component calls fetch(), Next.js caches the response on the server. The same fetch in a subsequent request — from a different user, seconds or hours later — returns the cached response without hitting the upstream API.

Scope: Shared across all users and all requests
Duration: Indefinite by default (until revalidated or cleared)
This is the most likely culprit for stale CMS content

// Cached indefinitely (default behavior)
const data = await fetch('https://cms.example.com/posts/1');

// Cached for 60 seconds (ISR-style revalidation)
const data = await fetch('https://cms.example.com/posts/1', {
  next: { revalidate: 60 }
});

// Never cached — always fetches fresh data
const data = await fetch('https://cms.example.com/posts/1', {
  cache: 'no-store'
});

The default behavior — indefinite caching — is aggressive. A fetch to an external API that returns data at build time will continue serving that same data until the cache is explicitly invalidated or the application is redeployed. This is the correct behavior for truly static content (documentation pages, product specs that rarely change), but wrong for content that editors update regularly.

Setting appropriate revalidation intervals:

// Product catalog — stale for 5 minutes is acceptable
const products = await fetch('https://api.store.com/products', {
  next: { revalidate: 300 }
});

// User-specific data — never cache (different per user, sensitive)
const profile = await fetch(`https://api.example.com/users/${userId}`, {
  cache: 'no-store'
});

// Static reference data — cache aggressively
const countries = await fetch('https://api.example.com/countries', {
  next: { revalidate: 86400 }  // 24 hours
});

On-demand revalidation for CMS webhooks:

The most precise caching strategy for CMS-driven content is time-based revalidation combined with on-demand invalidation on publish:

// Tag fetches with content identifiers
const post = await fetch(`https://cms.example.com/posts/${id}`, {
  next: {
    revalidate: 3600,  // Fallback: revalidate every hour
    tags: [`post-${id}`, 'posts']  // Tags for targeted invalidation
  }
});

const category = await fetch(`https://cms.example.com/categories/${slug}`, {
  next: {
    revalidate: 3600,
    tags: [`category-${slug}`, 'categories']
  }
});
// app/api/revalidate/route.ts — called by the CMS webhook on content publish
import { revalidatePath, revalidateTag } from 'next/cache';
import { NextRequest } from 'next/server';

export async function POST(request: NextRequest) {
  const { secret, postId, path } = await request.json();

  // Validate the webhook secret
  if (secret !== process.env.REVALIDATION_SECRET) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Invalidate specific post cache
  if (postId) {
    revalidateTag(`post-${postId}`);
  }

  // Invalidate specific path
  if (path) {
    revalidatePath(path);
  }

  // Invalidate all posts (e.g., when a post is deleted from a listing)
  revalidateTag('posts');

  return Response.json({
    revalidated: true,
    revalidatedAt: new Date().toISOString(),
  });
}

When the CMS publishes a change, it sends a POST to /api/revalidate with the post ID. Next.js immediately evicts that entry from the Data Cache. The next request for that post fetches fresh data from the CMS. No waiting for the revalidation interval.

Cache 3: Full Route Cache (static rendering, disk-backed)

Routes that Next.js statically renders at build time (or via ISR after the first request) are stored as HTML + RSC payload on the server. Requests for these routes return the cached HTML without re-rendering the React component tree.

Scope: Server-wide, shared across all users
Duration: Until revalidated (by revalidatePath, revalidateTag, or reaching the revalidate interval)
Opt out: export const dynamic = 'force-dynamic' on the route segment

The Full Route Cache is what makes Next.js serve static pages at CDN-like speed even when they contain data fetched from an API. The page is rendered once and served to thousands of users from memory.

// app/blog/[slug]/page.tsx

// Static: Next.js renders at build time, caches the HTML
export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug);
  return <PostContent post={post} />;
}

// generateStaticParams tells Next.js which slugs to pre-render
export async function generateStaticParams() {
  const posts = await fetchAllPostSlugs();
  return posts.map(slug => ({ slug }));
}

// Revalidate the cached page every 10 minutes
export const revalidate = 600;
// Force dynamic rendering — opt out of Full Route Cache
export const dynamic = 'force-dynamic';

// This route always re-renders on every request
// Use for: user-specific pages, pages with real-time data,
// pages that depend on request headers/cookies
export default async function UserDashboard() {
  const session = await getServerSession();
  const data = await fetchUserData(session.userId);
  return <Dashboard data={data} />;
}

The distinction between cache: 'no-store' on a fetch (opts out of Data Cache) and dynamic = 'force-dynamic' on a route (opts out of Full Route Cache) is important. A route can be dynamic (re-renders on every request) but still use cached fetch responses. Conversely, a route can be statically rendered but contain fetches with short revalidation windows — Next.js re-renders the route when those windows expire.

ISR (Incremental Static Regeneration) behavior:

When a statically rendered route is requested after its revalidate interval has passed:

  1. Next.js serves the stale cached version to the requesting user (immediate response)
  2. In the background, Next.js re-renders the route with fresh data
  3. The new version replaces the cached version
  4. The next request receives the fresh version

This is the "stale-while-revalidate" strategy. Users never wait for re-rendering — they might see content that is up to revalidate seconds old, but the response is always fast.

Cache 4: Router Cache (client-side, browser memory)

When a user navigates between pages in the App Router, Next.js caches the RSC payloads in the browser. Navigating back to a previously visited page shows the cached version instead of fetching from the server.

Scope: Per-browser session (per-tab memory)
Duration: 30 seconds for dynamic segments, 5 minutes for static segments
This is often the source of "why is my content still old after I revalidated the server cache"

The Router Cache is invisible until it causes problems. A user visits a product page, the price changes on the server, the user navigates away and comes back — they see the old price because the Router Cache serves the previously rendered page.

// Force the router cache to revalidate by calling router.refresh()
'use client';
import { useRouter } from 'next/navigation';

function RefreshButton() {
  const router = useRouter();
  return (
    <button onClick={() => router.refresh()}>
      Reload content
    </button>
  );
}

router.refresh() re-fetches data from the server without a full page reload, invalidating the client-side router cache for the current route. It preserves React state and scroll position while updating the content.

For pages where content freshness is critical, trigger a refresh after mutations:

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

function PublishButton({ postId }: { postId: string }) {
  const router = useRouter();
  const [isPending, startTransition] = useTransition();

  const handlePublish = async () => {
    await publishPost(postId);
    startTransition(() => {
      router.refresh();  // Invalidate router cache after mutation
    });
  };

  return (
    <button onClick={handlePublish} disabled={isPending}>
      {isPending ? 'Publishing...' : 'Publish'}
    </button>
  );
}

Diagnosing which cache is stale

A systematic approach to identifying the culprit:

Step 1: Open an incognito window and visit the page.

  • If content is still stale: the problem is on the server (Data Cache or Full Route Cache)
  • If content is fresh in incognito but stale in your normal browser: the problem is the Router Cache — use router.refresh()

Step 2: If the problem is server-side, add cache: 'no-store' temporarily to the relevant fetch.

  • If content becomes fresh: the Data Cache was holding stale data
  • If content is still stale: the Full Route Cache is serving pre-rendered HTML that includes the stale data

Step 3: If the Full Route Cache is the problem, trigger revalidatePath or deploy a new build.

  • revalidatePath('/blog/[slug]') evicts the specific route from the Full Route Cache
  • A new deployment clears the Full Route Cache for all routes

Step 4: For CMS content, verify the webhook is being called.

The most common cause of persistent stale CMS content is a missing or broken webhook. The CMS is not notifying the Next.js application that content has changed, so the Data Cache keeps serving the old response indefinitely.

For the developer's CMS title problem: her CMS was not sending a webhook to the revalidation endpoint. The Data Cache held the old title indefinitely. The fix: add a webhook from the CMS to /api/revalidate that fires on content publish. New titles appear within seconds of publishing.

Common mistakes with App Router caching

Mistake 1: Disabling all caching because of one stale content problem. Adding cache: 'no-store' to every fetch or dynamic = 'force-dynamic' to every route eliminates the performance benefits of caching and adds server load. Fix the specific cache layer that is causing the staleness instead.

Mistake 2: Relying on redeployment for content updates. If content editors need to deploy the application to publish a blog post, the CMS webhook setup is missing. On-demand revalidation with revalidateTag gives editors immediate publishing without touching the codebase.

Mistake 3: Using revalidatePath without understanding its scope. revalidatePath('/blog/[slug]', 'page') invalidates a specific page. revalidatePath('/blog', 'layout') invalidates the layout and all pages under it. Calling the wrong form invalidates too much (unnecessary re-renders) or too little (stale content remains).

Mistake 4: Not validating the revalidation webhook in production. A webhook without secret validation allows anyone to trigger cache invalidation, potentially hammering the origin server with re-render requests. Always validate the REVALIDATION_SECRET before processing the webhook.

Mistake 5: Expecting the Router Cache to behave like a browser cache. The Router Cache is controlled by Next.js, not by Cache-Control headers. Clearing browser cache, opening incognito, or calling router.refresh() affects it differently. Understanding that it lives in JavaScript memory (not HTTP cache) prevents a class of confusing debugging sessions.

Summary: the right cache settings by content type

Content typeData CacheFull Route Cache
Static documentationdefault (indefinite)static (no revalidate)
Blog posts / CMS contenttags: ['post-id'] + webhookrevalidate: 3600
Product listing (public)revalidate: 300revalidate: 300
Product prices / inventoryrevalidate: 60 or webhookrevalidate: 60
User-specific datacache: 'no-store'dynamic = 'force-dynamic'
Search resultscache: 'no-store'dynamic = 'force-dynamic'
Admin / authenticated pagescache: 'no-store'dynamic = 'force-dynamic'

The goal is to cache aggressively where content is shared and stable, and to opt out specifically where content is user-specific or changes frequently. Blanket cache disabling and blanket cache enabling are both wrong defaults — the right setting varies by route and by fetch.

Testing cache behavior in development

The Next.js development server does not replicate production cache behavior. The Data Cache and Full Route Cache behave differently in development — most caching is disabled or behaves differently to make iteration fast. Always test cache behavior against a production build:

next build && next start

To inspect what the Data Cache contains and when it was last populated, Next.js writes cache entries to .next/cache/fetch-cache in production builds on local machines. In Vercel deployments, the cache is hosted remotely and not directly inspectable — use the revalidation API endpoint to force invalidation during debugging.

For a systematic approach to verifying that webhooks are correctly invalidating the cache, add logging to the revalidation endpoint:

// app/api/revalidate/route.ts — with detailed logging
export async function POST(request: NextRequest) {
  const payload = await request.json();
  const { secret, tag, path } = payload;

  if (secret !== process.env.REVALIDATION_SECRET) {
    console.error('[revalidate] Unauthorized attempt', { ip: request.ip });
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  console.log('[revalidate] Invalidating', { tag, path, timestamp: new Date().toISOString() });

  if (tag) revalidateTag(tag);
  if (path) revalidatePath(path);

  return Response.json({ revalidated: true, tag, path });
}

Monitoring the logs during a CMS publish confirms whether the webhook is firing and which cache entries are being invalidated. If the log never appears after a publish, the webhook configuration is the problem — not the cache strategy.

Comments

No comments yet. Be the first!

Sign in to leave a comment.