Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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.

TriggerWhere it goesWhat it doesNeeds a request to fire
export const revalidate = <seconds>A page or layout segmentAfter the window lapses, the next request serves stale HTML and regenerates in the backgroundYes
revalidatePath("/path")A Server Action or route handlerMarks that path's cache stale immediatelyNo — you call it
revalidateTag("tag")A Server Action or route handlerMarks every cache entry carrying the tag staleNo — 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:

SymbolMeaningRows in this build
○Static — prerendered as static content27
●SSG — prerendered via generateStaticParams3
ƒDynamic — server-rendered on demand8

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:

FileCallsFires after
src/lib/actions/account.ts4A profile or settings write
src/lib/actions/redemption.ts1A license slot redemption
src/lib/actions/refund.ts1A 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

SituationISR?Why
Content comes from a headless CMSYesThe data changes without a deploy; this is the case ISR was built for
Prices or stock from an external API, stale-tolerantYesA short revalidate beats rendering per request
Content is a TS/MDX module in the repoNoA change requires a deploy, which rebuilds everything
Page shows per-user dataNoIt is already dynamic; see force-dynamic
You know exactly when the data changedPrefer on-demandrevalidatePath/revalidateTag is exact where a window is a guess
Thousands of pages, few ever visitedYesPair ISR with dynamicParams so cold paths render on first hit
Build time has grown painfulMaybeISR 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

SymptomCauseFix
revalidate set, page never updatesThe data is imported, not fetched — nothing was staleMove the data behind fetch() or a DB call, or drop the export
First visitor after the window sees old contentExpected: stale-while-revalidate serves cached HTML, then regeneratesUse revalidatePath if the update must be immediate
revalidatePath has no effectCalled outside a Server Action or route handler, or the path string does not match the routePass the route path exactly, e.g. /dashboard not /dashboard/
Works on Vercel, not self-hostedEach instance keeps its own in-memory cache by defaultShare a cache handler across instances
revalidate ignored entirelyThe route is already ƒ — nothing is cached to revalidateCheck the build's route table first
Build time climbing with the catalogEvery path is prerendereddynamicParams = 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.

Keep reading

Tutorial11 min read

Next.js JSON-LD: One Component, Seven Schema Types, 472 Pages

One 19-line Server Component emits seven schema types across 472 pages. The escape most tutorials omit, and two blocks we ship knowing they earn nothing.

Read more