Next.js ISR: 846 Prerendered Pages, Zero revalidate Exports
ISR refreshes static pages without a deploy. This build prerenders 846 pages and exports revalidate zero times, because compiled-in data has no staleness to correct.
Incremental Static Regeneration (ISR) rebuilds a prerendered Next.js page after a set interval, without a redeploy. You enable it with one export: export const revalidate = 3600. This storefront prerenders 846 pages and exports revalidate exactly zero times — because its content is compiled into the bundle, where a revalidation window has nothing newer to fetch.
Everything below is read out of a real next build for this codebase (Next.js 16.2.9, Turbopack), plus the six on-demand revalidation calls that do ship here. ISR is the right answer to a specific question, and the useful part of this post is learning to tell whether you are asking it.
The three triggers, and which one people mean
"ISR" gets used for all three of these. They behave differently enough that it is worth splitting them before anything else.
| Trigger | Where it goes | What it does | Needs a request to fire |
|---|---|---|---|
export const revalidate = <seconds> | A page or layout segment | After the window lapses, the next request serves stale HTML and regenerates in the background | Yes |
revalidatePath("/path") | A Server Action or route handler | Marks that path's cache stale immediately | No — you call it |
revalidateTag("tag") | A Server Action or route handler | Marks every cache entry carrying the tag stale | No — you call it |
The first is time-based ISR, and it is what "Next.js ISR" means in nearly every tutorial. The other two are on-demand revalidation: the cache invalidation half of the same system, triggered by your code instead of a clock.
The distinction matters because the first one is a guess and the other two are facts. A revalidate = 3600 is you betting that an hour is close enough. A revalidatePath is you knowing the data changed because you are the code that changed it.
The build this is measured against
npm run build here prerenders 846 pages:
✓ Compiled successfully in 19.8s
✓ Generating static pages using 3 workers (846/846) in 14.5s
The route table splits into three kinds of row:
| Symbol | Meaning | Rows in this build |
|---|---|---|
○ | Static — prerendered as static content | 27 |
● | SSG — prerendered via generateStaticParams | 3 |
ƒ | Dynamic — server-rendered on demand | 8 |
The three ● rows are the segments that multiply: /templates/[slug] prerenders 111 product pages, /blog/[slug] prerenders 349 posts, and /blog/[slug]/opengraph-image prerenders a social card for each of those posts. The eight ƒ rows are /login, /signup, /reset-password, /auth/callback, /dashboard, /dashboard/settings, /api/download and /api/webhooks/lemonsqueezy.
That split is the whole argument of this post, so it is worth stating plainly: every page whose content changes per visitor is already dynamic, and every page that is static is built from data that cannot change without a deploy. There is no third category left for time-based ISR to serve.
Why zero revalidate exports here
Grep the app directory for route segment config and you get two hits, neither of them ISR:
$ grep -rn "export const revalidate" src/app/
# (no output)
$ grep -rn "export const dynamic\|export const dynamicParams" src/app/
src/app/blog/[slug]/page.tsx:22:export const dynamicParams = false;
src/app/blog/feed.xml/route.ts:8:export const dynamic = "force-static";
The reason is the data layer. The product catalog is a TypeScript module — 8,114 lines, 111 products — imported directly by the route that renders it:
// src/app/templates/[slug]/page.tsx
import { catalog, getProduct, type TemplateProduct } from "@/data/catalog";
type Params = { slug: string };
export function generateStaticParams(): Params[] {
return catalog.map((p) => ({ slug: p.slug }));
}
Follow what revalidate = 3600 would do to that page. An hour after the deploy, a visitor arrives, Next.js serves the cached HTML and re-renders the page in the background. The re-render imports catalog — the same module, from the same bundle, with the same 111 products — and produces byte-identical HTML. The only thing the revalidation achieved was CPU.
Compiled-in data has no staleness to correct. The catalog changes when someone edits src/data/catalog.ts and ships it, and shipping it rebuilds all 846 pages anyway. The same holds for the 349 posts: they are MDX compiled at build time, so a new post is a deploy by construction.
This is the test worth internalising. ISR pays when your page's data lives somewhere your build output does not — a CMS, a database, a pricing API. If a content change requires a deploy, ISR is a no-op with a cache miss attached.
The half of ISR that does ship here
On-demand revalidation is a different story, because this app does have data that changes without a deploy: a buyer's purchases. There are six revalidatePath calls, all of them in Server Actions, all of them pointed at the dashboard:
// src/lib/actions/account.ts
import { revalidatePath } from "next/cache";
// ...after a successful profile write:
revalidatePath("/dashboard");
revalidatePath("/dashboard/settings");
The full inventory:
| File | Calls | Fires after |
|---|---|---|
src/lib/actions/account.ts | 4 | A profile or settings write |
src/lib/actions/redemption.ts | 1 | A license slot redemption |
src/lib/actions/refund.ts | 1 | A refund request |
Note what these are not: /dashboard is a ƒ route, rendered per request behind a session. The revalidation is not refreshing prerendered HTML — it is clearing the Next.js data cache for that path so the next render does not serve a stale read-through of the buyer's rows. Our Server Actions post works through the rule that decides which of the eight actions here revalidate and which redirect past the problem instead.
If this storefront ever grew an external CMS, this is where time-based ISR would arrive — and the honest version would still be on-demand first: a webhook from the CMS calling revalidatePath on the page that changed, with a long revalidate as the backstop for a missed webhook.
When ISR genuinely pays
| Situation | ISR? | Why |
|---|---|---|
| Content comes from a headless CMS | Yes | The data changes without a deploy; this is the case ISR was built for |
| Prices or stock from an external API, stale-tolerant | Yes | A short revalidate beats rendering per request |
| Content is a TS/MDX module in the repo | No | A change requires a deploy, which rebuilds everything |
| Page shows per-user data | No | It is already dynamic; see force-dynamic |
| You know exactly when the data changed | Prefer on-demand | revalidatePath/revalidateTag is exact where a window is a guess |
| Thousands of pages, few ever visited | Yes | Pair ISR with dynamicParams so cold paths render on first hit |
| Build time has grown painful | Maybe | ISR plus lazy params trades build time for first-visit latency |
That last pair is the one real cost of the choice made here. 846 pages take about 14 seconds to generate, which is fine; at 84,000 it would not be, and the fix is to stop prerendering the tail and let ISR fill it in on demand. The lever is dynamicParams, which is why this codebase sets it explicitly on the one route where the answer is not obvious:
// src/app/blog/[slug]/page.tsx
export const dynamicParams = false;
false means a slug outside generateStaticParams returns a 404 instead of trying to render. For a blog whose every post is a file in the repo, an unknown slug is a typo, not a page that exists yet — so failing fast is correct. On a catalog fed by a CMS, the default (true) plus revalidate is the ISR pattern everyone is actually reaching for.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
revalidate set, page never updates | The data is imported, not fetched — nothing was stale | Move the data behind fetch() or a DB call, or drop the export |
| First visitor after the window sees old content | Expected: stale-while-revalidate serves cached HTML, then regenerates | Use revalidatePath if the update must be immediate |
revalidatePath has no effect | Called outside a Server Action or route handler, or the path string does not match the route | Pass the route path exactly, e.g. /dashboard not /dashboard/ |
| Works on Vercel, not self-hosted | Each instance keeps its own in-memory cache by default | Share a cache handler across instances |
revalidate ignored entirely | The route is already ƒ — nothing is cached to revalidate | Check the build's route table first |
| Build time climbing with the catalog | Every path is prerendered | dynamicParams = true plus revalidate, and prerender only the popular slugs |
The first row is the common one, and the cheapest check is the build table: if your route prints ƒ, ISR is not running, whatever you exported.
Frequently asked questions
Does ISR work in the App Router?
Yes, with different syntax. The Pages Router returned revalidate from getStaticProps; the App Router exports const revalidate from the segment. The getServerSideProps migration post covers the rest of that translation.
Is revalidate = 0 the same as force-dynamic?
Effectively, for rendering mode — both mean "never serve a cached render." Prefer dynamic = "force-dynamic" when that is the intent, because it says so.
Can I use ISR with generateStaticParams?
Yes, and it is the strongest combination for big catalogs: prerender the known paths at build time, leave dynamicParams at its default so unlisted paths render on first request, and let revalidate refresh both.
Do I need ISR for a marketing site? Almost never. If the copy lives in the repo, a deploy is your revalidation, and 846 prerendered pages served from a CDN is the fastest thing you can ship. Static rendering in the App Router covers what silently breaks it.
Templates that stay static by default
If you want a Next.js site whose route table is almost entirely ○ — no revalidation windows to reason about, no cache handler to configure — these landing sets ship that way: ASoc Guard, ASoc Haven and ASoc Hearth.
Browse the sets: Next.js landing page templates and Tailwind landing page templates. For the full rendering picture, read Next.js Partial Prerendering next.
