Skip to content

Frontend

Next.js Caching, Explained Without the Hand-Waving

Four caches, different lifetimes, one confusing surface. Here is what each layer actually does, how to see which one served your page, and how to invalidate precisely instead of reaching for force-dynamic.

3 min read2 views
  • #nextjs
  • #caching
  • #performance
  • #react
  • #isr

Almost every "Next.js cached my stale data" bug comes from the same place: there isn't one cache. There are four, they have different lifetimes, and export const dynamic = 'force-dynamic' is the sledgehammer people reach for when they can't tell which one is involved.

Here's the map.

The four layers

Cache Lives in Scope Cleared by
Request Memoization server one render pass end of request
Data Cache server across requests and deploys revalidateTag, revalidatePath, time
Full Route Cache server across requests redeploy, data revalidation
Router Cache browser one session navigation, router.refresh()

Requests flow top to bottom. A page can be served from the Full Route Cache without touching your database — which is exactly what you want, and exactly what confuses people when the content is stale.

1. Request Memoization

Call fetch with the same URL and options twice in one render and React runs it once. This exists so a layout and a page can both ask for the same data without coordinating.

It only covers fetch. For a database client — Mongoose, Prisma, whatever — wrap it yourself:

lib/posts.ts
import { cache } from 'react';
 
export const getPostBySlug = cache(async (slug: string) => {
  await dbConnect();
  return Blog.findOne({ slug, published: true }).lean();
});

Now generateMetadata and the page component can both call getPostBySlug(slug) and you pay for one query. This is per-request only — it does not persist.

2. The Data Cache

This is the one that surprises people: it survives deployments. A fetch result cached with revalidate: 3600 is still there after you ship.

// Cached until explicitly revalidated
await fetch(url, { cache: 'force-cache' });
 
// Time-based — at most one refetch per hour
await fetch(url, { next: { revalidate: 3600 } });
 
// Tagged — invalidate on demand
await fetch(url, { next: { tags: ['posts'] } });
 
// Never cached
await fetch(url, { cache: 'no-store' });

In Next.js 15 the default changed: fetch is no longer cached by default. If you want caching you now ask for it. This was the right call — the old default was responsible for a lot of confusing bug reports.

For non-fetch data sources, the segment-level revalidate export drives the same behaviour:

app/blog/page.tsx
export const revalidate = 1800; // regenerate at most every 30 minutes

3. The Full Route Cache

At build time Next.js decides whether each route is static (rendered once, reused) or dynamic (rendered per request). A route goes dynamic the moment it touches request-scoped data:

  • cookies(), headers(), searchParams
  • fetch with cache: 'no-store'
  • export const dynamic = 'force-dynamic'

The trap is that these are contagious upward. One cookies() call in a deeply nested component makes the whole route dynamic. Check the build output — Next.js prints exactly what it decided:

Route (app)                          Size     First Load JS
┌ ○ /                                5.1 kB          112 kB
├ ● /blog/[slug]                     2.4 kB          109 kB
└ ƒ /dashboard                       8.2 kB          124 kB
 
○  (Static)   prerendered as static content
●  (SSG)      prerendered as static HTML with generateStaticParams
ƒ  (Dynamic)  server-rendered on demand

If something you expected to be shows up as ƒ, go find the request-scoped call. That build output is the fastest debugging tool in the framework and almost nobody reads it.

4. The Router Cache

Client-side. When you navigate with <Link>, the RSC payload for the previous route is kept in memory so going back is instant. It's also why a <Link>-driven navigation can show data from thirty seconds ago.

Clear it explicitly after a mutation:

'use client';
import { useRouter } from 'next/navigation';
 
export function DeleteButton({ id }: { id: string }) {
  const router = useRouter();
 
  return (
    <button
      onClick={async () => {
        await fetch(`/api/items/${id}`, { method: 'DELETE' });
        router.refresh(); // refetch the current route, keep client state
      }}
    >
      Delete
    </button>
  );
}

router.refresh() does not reset component state or lose scroll position — it just re-renders the server tree. It's the right tool far more often than window.location.reload().

Invalidating on purpose

The general pattern: cache aggressively, invalidate precisely.

app/actions.ts
'use server';
 
import { revalidatePath, revalidateTag } from 'next/cache';
 
export async function publishPost(slug: string) {
  await Blog.updateOne({ slug }, { $set: { published: true } });
 
  revalidatePath(`/blog/${slug}`); // one page
  revalidatePath('/blog');         // the index
  revalidateTag('posts');          // anything tagged 'posts'
}

For updates that come from outside the app — a CMS webhook, a cron job, the seed script for this blog — expose a route:

app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
 
export async function POST(req: Request) {
  const secret = req.headers.get('x-revalidate-secret');
  if (secret !== process.env.REVALIDATE_SECRET) {
    return NextResponse.json({ ok: false }, { status: 401 });
  }
 
  const { path } = await req.json();
  revalidatePath(path);
  return NextResponse.json({ ok: true, revalidated: path });
}

Guard it. An unauthenticated revalidation endpoint is a cheap way for someone to hammer your origin.

The defaults I ship with

// Marketing and content pages — regenerate hourly
export const revalidate = 3600;
 
// Blog index and articles — content changes more often
export const revalidate = 1800;
 
// Anything personalised or auth-gated
export const dynamic = 'force-dynamic';

Plus generateStaticParams on /blog/[slug] so every article is prerendered at build time, with dynamicParams = true so a post published after the build still renders on first request and then gets cached.

Debugging checklist

  1. Is it stale? Check the build output — is the route / (cached) or ƒ (dynamic)?
  2. Stale after a mutation? You need revalidatePath/revalidateTag, or router.refresh() on the client.
  3. Stale only when navigating with <Link>? That's the Router Cache. router.refresh().
  4. Stale after a deploy? That's the Data Cache surviving — it needs explicit revalidation.
  5. Slower than expected? Turn on logging: { fetches: { fullUrl: true } } in next.config.ts and watch which requests are actually hitting the network.

Four caches, four lifetimes. Once you can name which one you're fighting, the fix is usually one line — and it's almost never force-dynamic.