Caching behaviour changed noticeably between Next.js 14, 15 and 16. The concepts below are stable, but always confirm exact defaults in the documentation for the version you run.
The question behind every slow or stale page
Sooner or later every Next.js developer hits one of two symptoms:
- "I updated the content but the site still shows the old version."
- "Every request hits my database and the site feels slow."
Both have the same root cause: not knowing when a page is rendered and where its result is stored. The App Router gives you a spectrum of options between "rendered once at build time" and "rendered on every request", and the right choice is different for a marketing page, a blog, a dashboard and a checkout.
This article builds a mental model you can apply to any route, then walks through the traps that catch people in production. The examples come from a real project: a portfolio whose content lives in MongoDB and is edited from an admin panel.
Three moments a page can be rendered
Forget framework jargon for a second. There are only three moments at which HTML can be produced:
| Moment | Name | Cost per visitor | Freshness |
|---|---|---|---|
| At build time | Static rendering | ~0 (served from CDN) | Frozen until the next deploy |
| At build time, then refreshed in the background | ISR (Incremental Static Regeneration) | ~0 for most visitors | Stale by at most N seconds |
| On every request | Dynamic rendering | A full render each time | Always fresh |
Everything else — revalidatePath, unstable_cache, streaming — is a tool for moving a route along that line.
What decides which mode a route gets?
Next.js infers the mode from what your code does. A route is static by default, and becomes dynamic when it touches something that can only be known at request time:
- reading
cookies()orheaders() - reading
searchParamsin a page - using
connection()or explicitly exportingexport const dynamic = "force-dynamic" - fetching with an explicit
cache: "no-store"
The build output tells you exactly what happened. Learn to read those little symbols:
Route (app) Revalidate
┌ ○ / 1m
├ ● /blog/[slug] 1m
├ ƒ /admin
└ ƒ /api/contact
○ (Static) prerendered as static content
● (SSG) prerendered using generateStaticParams
ƒ (Dynamic) server-rendered on demand
If a page you expected to be ○ shows up as ƒ, something in its tree — often a layout — is reading cookies() or headers(). One dynamic API call in a layout makes every page beneath it dynamic. That is the number one accidental performance regression I see.
ISR in one paragraph
ISR is stale-while-revalidate for pages. You tell Next.js how long a rendered page may be considered fresh:
// app/blog/page.tsx
export const revalidate = 60; // seconds
export default async function BlogPage() {
const posts = await getPosts(); // any data source — database, CMS, API
return <PostList posts={posts} />;
}
The first visitor after the interval still receives the cached page instantly. In the background Next.js re-renders it, and once that succeeds, later visitors get the new version. Nobody ever waits for a render, which is why ISR is the sweet spot for content sites.
Two subtleties trip people up:
- The revalidation is lazy. Nothing happens until someone requests the page after the interval has elapsed. A page nobody visits is never regenerated.
- A failed regeneration keeps serving the old page. That is a feature — your site survives a database outage — but it also means errors can hide. Log them.
Pairing ISR with a database (not fetch)
Most documentation shows fetch examples. Real applications often talk to a database driver directly, and there the fetch caching options simply do not apply. The route-level revalidate export is what you want, and it works with any data source.
Here is the pattern I use for a portfolio backed by MongoDB:
// lib/data.ts
export async function getProjects() {
await connectDB();
return ProjectModel.find({ published: true })
.sort({ order: 1 })
.lean();
}
// app/projects/page.tsx
export const revalidate = 60;
Now visitors are served from cache and the database sees roughly one query per minute per page, no matter how much traffic arrives.
Dynamic segments and generateStaticParams
For a route like /blog/[slug] you have two options. You can pre-render every slug at build time, or generate pages on first request and cache them. The second option has a big advantage when the database is not reachable during the build (for example on a CI machine):
// app/blog/[slug]/page.tsx
export const revalidate = 60;
// Render nothing at build time; generate each page on first visit, then cache it.
export async function generateStaticParams() {
return [];
}
The result is marked ● in the build output: static-like behaviour, built lazily.
On-demand revalidation: make edits appear instantly
Waiting up to a minute after publishing a post is fine for readers but frustrating for the author staring at the site. On-demand revalidation lets a write operation purge the cache immediately:
import { revalidatePath } from "next/cache";
export async function PUT(req: Request, { params }: Ctx) {
// ...validate, authorise and save to the database
revalidatePath("/", "layout"); // purge every page under the root layout
return Response.json({ ok: true });
}
The second argument matters. revalidatePath("/blog") purges that one URL; revalidatePath("/", "layout") purges everything that shares the root layout. For a small site the blunt version is perfectly fine and removes a whole class of "I forgot to purge the tag" bugs.
For larger applications, tags give you surgical control: label cached data with tags: ["posts"], then call revalidateTag("posts") when a post changes. Only the pages that depend on posts are refreshed.
Caching the data instead of the page
Sometimes the page must be dynamic (it depends on the logged-in user) but a part of its data is expensive and shared — a leaderboard, a pricing table, a list of categories. Cache the data function, not the page.
Historically this was unstable_cache. Newer Next.js versions add a "use cache" directive as part of the Cache Components model, which you opt into in configuration. The idea is identical in both: memoise the result of an async function for some time, keyed by its arguments, so that dynamic pages can still reuse expensive shared work. Check your version's docs for the exact API; the design question — what can be shared between users, and for how long? — is the same everywhere.
Streaming: make the slow part not block the fast part
Not everything can be cached. When a page has a fast shell and one slow, personalised widget, do not make the whole page wait. Wrap the slow part in <Suspense> and let the shell stream immediately:
import { Suspense } from "react";
export default function Dashboard() {
return (
<>
<Header />
<Suspense fallback={<StatsSkeleton />}>
<SlowStats /> {/* async server component */}
</Suspense>
</>
);
}
The user sees a usable page in a few hundred milliseconds instead of staring at a blank tab for two seconds. A loading.tsx file next to a route does the same for the route as a whole.
A decision table you can actually use
| Page | Recommended strategy | Why |
|---|---|---|
| Landing / marketing | Static or ISR (revalidate in minutes to hours) | Identical for everyone |
| Blog post | ISR + on-demand revalidation on publish | Read-heavy, edits are rare |
| Blog / product listing | ISR | Same as above, small payload |
| Search results | Dynamic (or client-side fetch) | Depends on the query |
| Dashboard | Dynamic + streaming | Personalised per user |
| Checkout / account | Dynamic, never cached | Sensitive and user-specific |
| Admin panel | Dynamic (force-dynamic), noindex | Must never show stale data |
Pitfalls I have personally stepped on
1. Personalised data inside a cached page. If a cached page renders the current user's name, the first visitor's name can be served to everyone. Anything user-specific belongs in a dynamic route or a client component that fetches after load.
2. Build-time database access. If next build runs where the database is unreachable, static pages that query it will fail or render empty. Fail soft — return empty data with a short revalidate — and prefer generateStaticParams() { return [] } for dynamic segments.
3. Environment variables baked in at build. Only NEXT_PUBLIC_* variables are inlined into client bundles, and they are inlined when you build, not when you run. Changing one requires a new build.
4. The client-side Router Cache. The browser also caches visited routes for instant back/forward navigation. Recent Next.js versions made page segments uncached on the client by default, but if you see stale data only in the browser and not in curl, this is the layer to inspect. router.refresh() clears it.
5. Forgetting that ISR needs a running server or platform support. On a static export (output: "export") there is no server to regenerate anything; features like ISR and revalidatePath require a Node server or a platform such as Vercel that provides the equivalent.
How to debug "why is this stale?"
I go through the layers in a fixed order, from closest to the user outward:
- Browser — hard refresh, or
curlthe URL to bypass the client Router Cache. - CDN — inspect the response headers (
x-vercel-cache: HIT | STALE | MISS, orcache-control). - Full Route Cache — is the route
○/●when you expectedƒ, or the reverse? Check the build output. - Data layer — is there an
unstable_cache, a tag, or arevalidateon the data function? - The source of truth — did the write actually reach the database?
Ninety percent of the time the answer is found by the second step.
Summary
- Pages are rendered at build, in the background (ISR) or per request. Pick the cheapest moment that is still fresh enough.
- Reading
cookies(),headers()orsearchParamsmakes a route dynamic — and a layout that does so drags its whole subtree along. - For database-backed content,
export const revalidate = NplusrevalidatePathafter writes gives near-static speed with near-instant edits. - Cache data when pages must be dynamic; stream when part of a page is slow.
- Read the build output. It is the single most honest description of what your app really does.