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:
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:
export const revalidate = 1800; // regenerate at most every 30 minutes3. 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(),searchParamsfetchwithcache: '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 demandIf 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.
'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:
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
- Is it stale? Check the build output — is the route
○/●(cached) orƒ(dynamic)? - Stale after a mutation? You need
revalidatePath/revalidateTag, orrouter.refresh()on the client. - Stale only when navigating with
<Link>? That's the Router Cache.router.refresh(). - Stale after a deploy? That's the Data Cache surviving — it needs explicit revalidation.
- Slower than expected? Turn on
logging: { fetches: { fullUrl: true } }innext.config.tsand 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.