ISR vs Static Generation on a 50,000-Page Directory: What Actually Breaks
Picking ISR over full static generation is not a performance decision. It is a build-ceiling and cache-billing decision — and the failure mode that catches most directories is documented in one line of the Next.js reference.

The usual framing of this decision is wrong. People argue ISR versus static generation as if it were about page speed, and it isn't — both serve a prerendered HTML document from a CDN edge, and a visitor cannot tell them apart. The decision is about two things a benchmark will never show you: whether your build finishes at all, and what your cache costs per month once 50,000 listing pages start regenerating.
And there is a third thing, which is the one that actually catches people. It is
documented in a single line in the Next.js reference for generateStaticParams:
During revalidation (ISR),
generateStaticParamswill not be called again.
Read that again with a directory in your head. You prerender your listings at build time. A business submits a new listing on Tuesday. Revalidation runs on schedule, every page refreshes — and the new listing's page is still not in the prerendered set, because the function that decides which pages exist does not run during revalidation. Whether that URL resolves at all now depends entirely on a config flag you may never have set.
That is the shape of this problem. Let's work through it properly.
What each mode costs you at build time
Full static generation means generateStaticParams returns every slug you have,
and next build renders all of them. Fifty thousand listings, fifty thousand
HTML documents plus their RSC payloads, written to disk before the deployment
goes live.
The ceiling here is hard and public. Vercel's limits page puts build time per deployment at 45 minutes on Hobby, Pro and Enterprise alike — it is not a plan you can buy your way past. The same page is unusually direct about what large static output does to that budget:
Although there is no upper limit for output files created during a build, you can expect longer build times as a result of having many thousands of output files (100,000 or more, for example). If the build time exceeds 45 minutes then the build will fail.
A 50,000-listing directory that also generates category pages, city landing pages and paginated indexes is not far from that 100,000-file figure. Each page is cheap on its own; the problem is that build cost scales linearly with page count while your deploy window does not scale at all. The first time this bites is rarely at 50,000 pages — it is at 12,000 pages plus a slow upstream database, where each page render waits on a query and the arithmetic stops working.
ISR moves that work out of the build. You prerender a subset, and everything else
renders on first request and is then cached. next build stays roughly constant
no matter how much data you import. In exchange you take on a per-request cost
profile and a cache you now have to reason about.
The hybrid every directory actually wants
Almost nobody should pick a pure strategy here. The pattern that fits a directory is: prerender the pages that already have demand, let the long tail render on demand.
The Next.js docs call this
a subset of paths at build time —
return a partial list from generateStaticParams and let
dynamicParams
decide what happens to everything else. Left at its default of true, an
ungenerated path is server-rendered on first request and cached. Set to false,
it 404s.
That single flag is the whole decision, and it is worth being explicit about what each setting means for a directory:
dynamicParams | Ungenerated listing URL | Fits a directory when |
|---|---|---|
true (default) | Renders on first request, then cached | Listings are added continuously between deploys — the normal case |
false | Returns 404 | Your listing set is closed and only changes at deploy time |
If you are running a directory where owners submit listings, false is a trap
dressed as a safety feature. It turns every listing created since your last
deploy into a 404 — including the URL you just emailed the owner who submitted
it.
This is the shape our own local-SEO pages ship in. The
local SEO feature generates
{category}-in-{city}-{region} pages from live listing data, and the config
exposes prerenderTopN: 250 — the top combinations are prerendered at build,
the rest render on demand. The number is a dial, not a doctrine: raise it when
build time is comfortable, lower it when it isn't.
The trap: your prerendered set is frozen at build time
Back to that line from the reference. generateStaticParams runs during
next build and does not run again during revalidation. Practically, this means
your build-time set of prerendered paths is frozen until the next deploy.
With dynamicParams: true this is survivable — new listings just take the
on-demand path, render once, and get cached. The cost is that the first visitor
to each new listing waits for a real render, and if your data layer is slow, so
is that first paint. If that first visitor is Googlebot, it is Googlebot that
waits.
The mitigation is boring and effective: rebuild on a schedule that matches your listing velocity. If you import a few hundred listings a week, a nightly production deploy folds them into the prerendered set and keeps the on-demand path as an exception rather than the norm. If you import a few thousand a day, stop trying to prerender the tail and tune your database queries instead, because that is what every first request is now waiting on.
One version-specific note, since this changes underfoot: if you enable
Cache Components in
Next.js 16, generateStaticParams must return at least one param — an empty
array becomes a build error rather than "render everything at runtime". Check
which model you are on before copying a snippet from a 2024 blog post.
ISR billing is a function of what changes, not what you serve
Here is the part that surprises people who have only ever paid for bandwidth.
Vercel bills ISR reads and writes separately from CDN traffic. CDN cache reads and writes are free; the durable ISR cache is metered, in 8 KB units for both reads and writes. Storage itself is unlimited, and entries persist until you invalidate them or they go unaccessed for 31 days.
The critical sentence is this one:
When revalidation runs and the content hasn't changed from the previous version, no ISR write units are incurred.
So write cost is not driven by how many pages you have or how often they revalidate. It is driven by how often the rendered output actually differs. A directory whose listing data changes twice a year can revalidate hourly and pay almost nothing in writes — as long as the output is deterministic.
Which is where directories quietly break their own bill. Vercel's debugging
advice for unexpected writes names two culprits directly: new Date() and
Math.random() in the ISR output. Now count how many directory listing pages
render something like "Updated 3 hours ago", a "Trending this week" shuffle, or a
copyright year computed at render time. Every one of those makes the output
differ on every single revalidation, which converts a free no-op into a full
write across your entire catalogue.
The arithmetic is worth doing on your own numbers rather than trusting mine, so here is the model with the assumption stated out loud. Say a listing page's cached output is 40 KB — that is 5 write units at 8 KB per unit:
| Scenario | Pages rewritten per cycle | Write units per cycle | Cycles/day | Write units/day |
|---|---|---|---|---|
| Deterministic output, data unchanged | 0 | 0 | 24 | 0 |
| Timestamp in the output, hourly revalidate | 50,000 | 250,000 | 24 | 6,000,000 |
| Timestamp removed, daily revalidate | 50,000 | 250,000 | 1 | 250,000 |
Same site, same traffic, same page count. The difference between the first row and the second is one line of JSX.
Measure your own page size instead of borrowing the 40 KB — the HTML is a floor rather than an exact figure, since the RSC payload is cached alongside it:
curl -s https://your-directory.com/listing/some-slug | wc -cLengthening the revalidation interval on a page that rewrites itself every cycle only makes the bill arrive more slowly. Remove the non-deterministic output first, then tune the interval. Relative timestamps belong in a client component that computes them in the browser, not in the cached server output.
Revalidation: pick on-demand, and know what it does not do
Time-based revalidation is a blunt instrument on a directory, because listing data does not change on a timer — it changes when someone edits a listing, approves a claim, or finishes an import. That is exactly what on-demand revalidation is for.
Two behaviours worth internalising from the ISR guide before you build a flow around it:
revalidatePath invalidates, it does not regenerate. The docs are explicit that
"regeneration happens on the next request". Call it after a listing edit and the
page is not rebuilt at that moment — the next visitor triggers the rebuild and
waits for it. For an admin saving a listing this is fine. For a bulk import that
invalidates 8,000 paths at once, you have just queued 8,000 cold renders for
whoever arrives next, which will often be a crawler.
Self-hosting changes the rules again. Per the same guide, when you run multiple instances the default filesystem cache is per-instance, and on-demand revalidation only invalidates the instance that receives the call. Two containers behind a load balancer will disagree about your listing until both happen to be hit. If you self-host a directory across more than one instance, a shared cache handler is not optional.
And the caveat that catches people running background jobs: background regeneration runs on the instance that receives the triggering request, and on platforms with per-request billing that background work counts as additional compute. Regeneration is not free just because the visitor did not wait for it.
Sitemaps: the 50,000 number is not a coincidence
If you are at 50,000 pages you have already hit the other 50,000 limit. Google's
sitemap documentation
is unambiguous: "All formats limit a single sitemap to 50MB (uncompressed) or
50,000 URLs." Next.js handles the split with
generateSitemaps,
which shards into /sitemap/[id].xml files you then reference from an index.
There is a rendering-mode consequence hiding in the same Google page that most
sitemap posts miss. Google says it uses <lastmod> "if it's consistently and
verifiably accurate", and that the value "should reflect the date and time of the
last significant update to the page" — explicitly noting that a copyright date
change is not significant.
Now connect the two halves of this article. If your listing page renders a
timestamp that moves on every revalidation, and your sitemap derives lastmod
from "when did this page last change", you are simultaneously paying for
unnecessary ISR writes and telling Google that 50,000 pages changed materially
last night. One of those costs money. The other costs credibility with a crawler
that has to decide how much of your catalogue is worth recrawling — which is the
same crawl-budget problem we walked through in
programmatic SEO for directories.
How to check what your site is actually doing
None of this needs to be taken on faith. The ISR guide documents a response
header that tells you the cache state directly — x-nextjs-cache, with values
HIT (served from cache), STALE (served from cache, revalidating in
background), MISS (not in cache, rendered fresh) and REVALIDATED (regenerated
on demand).
Pull it on a handful of your own listing URLs:
curl -sI https://your-directory.com/listing/some-slug | grep -i x-nextjs-cacheA prerendered page you have not touched should return HIT. A page you know was
not in generateStaticParams should return MISS on its first request and HIT
afterwards. If a page you expected to be prerendered returns MISS every time,
your route is rendering dynamically and you have lost ISR entirely — check
whether any fetch on that route uses no-store or revalidate: 0, which the
docs note is enough to make the whole route dynamic.
Locally, NEXT_PRIVATE_DEBUG_CACHE=1 with next build && next start logs cache
hits and misses to the server console, which is the fastest way to see which
pages your build actually produced. That check belongs in your pre-launch
routine alongside the sitemap audit we ran across five live directory sites in
this URL-level audit.
When each mode is genuinely the right answer
Being honest about the boundaries:
Full static generation wins when your catalogue is closed and modest — under roughly 5,000 pages, updated on a schedule you control. Builds finish, there is no metered cache, no revalidation semantics to reason about, and no first-visitor penalty. A curated award list or an annual industry index should not be running ISR.
ISR wins in the middle, which is where most directories live: enough pages that a full build is uncomfortable, listing data that changes between deploys, and traffic concentrated on a minority of pages while a long tail sits mostly idle.
Dynamic rendering wins when the page genuinely differs per visitor — search results, faceted filters, anything personalised. Do not try to cache your way out of that; you will end up with a cache key per filter combination and a bill to match. Keep those routes dynamic and keep the listing pages cached.
The mistake is treating this as one global setting. A directory has at least three page classes with three different answers, and the framework lets you set the policy per route segment. Use that.
What to do tomorrow morning
Four things, in order, each one checkable:
- Count your output. Run a production build and look at how many pages it
emits and how long it takes. If you are past 20 minutes, you are one data
import away from the 45-minute wall, and the fix is
prerenderTopN-style subsetting, not a faster machine. - Check
dynamicParamson every listing route. If it isfalseanywhere that accepts new listings between deploys, you are serving 404s to newly submitted businesses right now. - Grep for non-determinism in cached output. Run the audit prompt above. Every relative timestamp in a cached server component is a full rewrite of that page on every revalidation cycle, forever.
- Curl
x-nextjs-cacheon ten listing URLs. Two minutes of work that tells you whether your rendering strategy is the one you think you configured.
Then set a rebuild cadence that matches how fast you add listings, and stop thinking about it.