Next.js has four caches. They interact. They don't always invalidate the way you'd guess. Most production incidents I've debugged in the last year on Next.js apps trace back to a misunderstanding of one of the four. usually the Data Cache or the Full Route Cache.
This is the cheat sheet I wish I'd had two years ago.
The Four Caches
| Cache | Lives in | Scope | Invalidation |
|---|---|---|---|
| Request Memoization | React render | Per request | Automatic |
| Data Cache | Server (persistent) | Per fetch URL + options | revalidatePath, revalidateTag, time |
| Full Route Cache | Server (persistent) | Per route segment | Data Cache invalidation, redeploy |
| Router Cache | Browser (memory) | Per session | Time, navigation, router.refresh() |
Read that table twice. Most caching bugs I see come from confusing the Data Cache (server, persistent across requests) with the Router Cache (browser, in-memory, session-scoped).
Request Memoization. Free Deduplication
If two Server Components in the same render call fetch("/api/user") with the same URL and options, only one network request is made. React deduplicates them automatically within a single render.
// Both of these resolve from the same fetch
async function Header() {
const user = await fetch("/api/me", { cache: "no-store" }).then((r) => r.json());
return <div>{user.name}</div>;
}
async function Sidebar() {
const user = await fetch("/api/me", { cache: "no-store" }).then((r) => r.json());
return <div>{user.email}</div>;
}
Even with cache: "no-store" (which disables the Data Cache), Request Memoization still kicks in because it lives one level higher. This is why you can write Server Components like they have free access to data. the duplication is handled.
The Data Cache. The Source of Most Bugs
The Data Cache is the persistent server-side cache for fetch() responses. Its default behaviour shifted dramatically in Next.js 15: fetch() is now uncached by default. You opt into caching explicitly.
// Uncached - fresh on every request
const data = await fetch(url);
// Cached forever (until revalidation)
const data = await fetch(url, { cache: "force-cache" });
// Cached for 60 seconds, then revalidated in the background
const data = await fetch(url, { next: { revalidate: 60 } });
// Cached and tagged - revalidate on demand
const data = await fetch(url, { next: { tags: ["posts"] } });
Tags are the most powerful pattern. From a Server Action, you can invalidate everything tagged "posts" after a write:
"use server";
import { revalidateTag } from "next/cache";
export async function createPost(data: FormData) {
await db.posts.create({ data: parse(data) });
revalidateTag("posts");
}
Every page that fetched with tags: ["posts"] will pull fresh data on its next request. This is the cleanest mutation-to-cache pattern in any framework I've used.
The trap: revalidateTag only invalidates the Data Cache. The Full Route Cache (next section) might still serve a stale rendered page. For static routes, also call revalidatePath.
The Full Route Cache. Pre-rendered Pages
Static routes (no dynamic data, or all data cached) are pre-rendered at build time and served from the Full Route Cache. This is what makes Next.js feel instant.
// Static - rendered at build, cached forever
export default async function HomePage() {
return <h1>Hello</h1>;
}
// Dynamic - rendered per request, no Full Route Cache
export default async function ProfilePage() {
const user = await getCurrentUser(); // reads cookies → dynamic
return <h1>{user.name}</h1>;
}
A route becomes dynamic the moment it touches request-specific data: cookies(), headers(), searchParams, or an uncached fetch(). Once dynamic, Next.js skips the Full Route Cache and renders fresh on every request.
Use the route config to be explicit:
export const dynamic = "force-static"; // fail build if dynamic
export const dynamic = "force-dynamic"; // never use Full Route Cache
export const revalidate = 3600; // ISR - revalidate hourly
ISR. The Best of Both
Incremental Static Regeneration (ISR) gives you static performance with periodic freshness. It's the right default for content that changes occasionally. blog posts, product pages, marketing copy.
// app/posts/[slug]/page.tsx
export const revalidate = 3600;
export async function generateStaticParams() {
const slugs = await db.posts.findMany({ select: { slug: true } });
return slugs.map((s) => ({ slug: s.slug }));
}
export default async function PostPage({ params }) {
const { slug } = await params;
const post = await db.posts.findUnique({ where: { slug } });
return <article>{/* ... */}</article>;
}
This page is built statically at deploy time, served from the cache, and re-generated in the background once an hour. Stale-while-revalidate, on autopilot.
For "publish a post and see it live in 30 seconds" workflows, combine with revalidatePath:
"use server";
import { revalidatePath } from "next/cache";
export async function publishPost(slug: string) {
await db.posts.update({ where: { slug }, data: { published: true } });
revalidatePath(`/posts/${slug}`);
revalidatePath("/posts"); // index page too
}
The Router Cache. The Stale-on-Back-Button Trap
The Router Cache is browser-side. It caches RSC payloads so back-navigation feels instant. The catch: by default, it can serve stale data after mutations.
After a Server Action that changes data, call:
import { useRouter } from "next/navigation";
const router = useRouter();
router.refresh();
Or, more reliably, use revalidatePath from the action. it instructs the Router Cache to refetch the affected segment on next navigation.
If you've ever shipped a "delete item, navigate back, item still shows" bug. this is why.
Production Pattern: A Caching Strategy by Page Type
This is the rubric I use in code review:
- Marketing / blog / docs:
force-static+ ISR withrevalidatePathfrom a webhook on CMS publish - Product / catalogue pages:
revalidate: 3600with on-demand invalidation when inventory changes - Dashboards: dynamic,
cache: "no-store", with Suspense for slow panels - User-specific pages (profile, settings): dynamic, no Data Cache (the caching key is per-user, which the Data Cache doesn't natively handle)
Get those four right, and 90% of caching bugs disappear.
Debugging Caching in Production
Two flags I always set during a caching investigation:
// In any Server Component
console.log("[cache]", { dynamic: !!cookies(), now: Date.now() });
# In your build
NEXT_PRIVATE_DEBUG_CACHE=1 next build
The build output will tell you which routes are static, which are dynamic, and (in 15+) which fetch calls are being cached vs not. Read that report on every PR. it's the single best signal of accidentally turning a static route dynamic.