Skip to main content

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.

DirectoryLaunch Team13 min read
ISR vs Static Generation on a 50,000-Page Directory: What Actually Breaks

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), generateStaticParams will 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:

dynamicParamsUngenerated listing URLFits a directory when
true (default)Renders on first request, then cachedListings are added continuously between deploys — the normal case
falseReturns 404Your 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:

ScenarioPages rewritten per cycleWrite units per cycleCycles/dayWrite units/day
Deterministic output, data unchanged00240
Timestamp in the output, hourly revalidate50,000250,000246,000,000
Timestamp removed, daily revalidate50,000250,0001250,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 -c
Audit for non-determinism before you tune revalidate

Lengthening 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-cache

A 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:

  1. 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.
  2. Check dynamicParams on every listing route. If it is false anywhere that accepts new listings between deploys, you are serving 404s to newly submitted businesses right now.
  3. 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.
  4. Curl x-nextjs-cache on 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.