Next.js

generateStaticParams for Hundreds of Routes Without OOM

Prerendering hundreds of pages without a twenty-minute build or an out-of-memory failure: fetch once, tune workers, leave the long tail dynamic, measure it.

5 min read
Next.jsBuildPerformance
998 words5 min read

The site that grew

A business site with a dozen pages builds in a minute. Then it gains a blog with 300 posts, 15 vertical landing pages with 160 sub-pages, 23 country pages and tag hubs. Now generateStaticParams yields over 500 routes, the build takes twelve minutes, and one morning the CI runner dies with a heap out-of-memory error. Nothing is wrong with the code. The build is doing 500 times more work than it was, and a few defaults do not scale.

Here is what to change, in order of effect.

Measure first

next build prints per-route timing in verbose mode and a summary of static versus dynamic routes. The two numbers to know are how long the "Generating static pages" phase takes and how many routes are in it. Divide for the per-page cost. If 500 pages take 600 seconds, each page costs over a second, and the question is why a page render costs a second.

Usually: it is fetching data.

Cause 1: fetching per page what could be fetched once

generateStaticParams returns the list of slugs. Then each page's component fetches its own data. If that fetch is a CMS call or a filesystem read plus MDX compile, 500 pages make 500 calls, serially or with limited concurrency.

Fix: fetch the collection once and share it. Next.js deduplicates identical fetch calls within a build via the data cache; for non-fetch data sources (a CMS SDK, filesystem reads) use React's cache() or a module-level memo so the second page's call is a lookup, not a fetch:

TypeScript
import { cache } from 'react'

export const getAllPosts = cache(async () => {
  // read and parse all MDX once per build worker
  return readAndParseAll()
})

export const getPost = cache(async (slug: string) => {
  const posts = await getAllPosts()
  return posts.find((p) => p.slug === slug)
})

With use cache on the collection function, the same applies and the cached result persists across the build's workers where the platform supports it.

For our own site, the landing sub-pages are generated from JSON into a TypeScript module before the build, so the per-page cost is an import, not a read.

Cause 2: expensive work inside each page render

MDX compilation, syntax highlighting with a full grammar set, image dimension probing, markdown-to-HTML with plugins. Each is fine once and slow 500 times.

Fix: move it out of render. Compile MDX at build in a pre-step and import the result, or cache the compiled output with use cache keyed on the slug so it survives across renders. Restrict the highlighter to the languages actually used. Probe image dimensions once into a manifest. The rule: anything that produces the same output for the same input every build should run once and be cached, not run per page.

Cause 3: memory

Each worker holds the data it loaded. If the "load everything" function returns a large object and each of the eight build workers loads it, that is eight copies. If the MDX compiler retains ASTs, memory climbs across pages. Eventually the heap limit is hit.

Fixes, in order:

  • Reduce what is held. Return only what pages need from the collection loader; do not carry full post bodies in the list used for generateStaticParams.
  • Raise the heap limit: NODE_OPTIONS=--max-old-space-size=4096 next build. A blunt tool that works when memory is genuinely needed.
  • Lower worker concurrency: experimental.cpus in next.config (or the staticGenerationMaxConcurrency and staticGenerationMinPagesPerWorker settings in recent versions) trades speed for memory.
  • Check for leaks: a module-level array that grows per page, a cache that never evicts.

Cause 4: everything is static that need not be

500 tag pages of which 480 get no traffic. Ten thousand product pages. Prerendering all of them at build is a choice, not a requirement.

Options:

  • dynamicParams = true (the default) with generateStaticParams returning only the important subset. The rest are rendered on first request and cached. Build prerenders 50 pages; the other 450 are generated on demand.
  • ISR via revalidate, or in the Cache Components model, use cache with a cacheLife so on-demand pages are cached after first render.
  • Leave genuinely long-tail routes fully dynamic.

For a business site, prerender the pages that matter for SEO and first-visit speed (services, verticals, countries, recent posts) and let the archive fill on demand.

Cause 5: Turbopack and the build itself

Next.js 16 builds with Turbopack by default, which is faster at compilation. The static generation phase is separate and is bounded by your page render cost, so Turbopack does not fix causes 1 to 3. Where Turbopack helps is in incremental rebuilds and dev; where it can hurt is memory on very large module graphs, for which the same NODE_OPTIONS fix applies.

Check the persistent build cache is working on your CI: .next/cache restored between builds makes the compile phase a fraction of cold.

A checklist for a 500-route site

  1. Collection loaded once per worker via cache() or use cache; per-page lookups are in-memory.
  2. Expensive transforms (MDX, highlighting) cached or pre-computed.
  3. Collection loader returns slim records; bodies loaded per page.
  4. Only SEO-critical routes in generateStaticParams; the tail on demand.
  5. Heap limit set appropriately; worker count tuned if memory is tight.
  6. .next/cache persisted in CI.
  7. Build time and route count logged per build so a regression is visible.

Our own site at over 500 routes builds its static pages in about twelve seconds with nine workers after applying the first three. Before, it was minutes, and the difference was entirely fetching and compiling once instead of per page.

Where this fits

Build performance is part of what we look at on any Next.js project that has content at scale, because a twenty-minute build changes how a team works: fewer deploys, slower fixes, and a temptation to skip preview builds. Twelve seconds keeps the site cheap to change, which is the point of building it this way.

All writingHire me for this