Engineering

How to Scale Next.js App Router for 100k+ Pages

Learn the architecture patterns and deployment strategies required to scale Next.js App Router applications to handle massive page volumes.

Rohan

Rohan

Co-Founder & Cloud / DevOps Engineer

7 min read

When a programmatic SEO build grows from a few hundred pages to tens of thousands, the thing that breaks first is almost never the part people brace for. Teams tend to over-prepare for build compute and under-prepare for the routing layer, and that mismatch is exactly backwards once you've actually shipped one of these at scale.

The volume problem is not what breaks first

Static export turns every route your app defines into a file on disk at build time. That's a good deal for a site that lives or dies on Core Web Vitals and CDN cache hits, since there's no server doing work on each request. The tradeoff is that the entire page inventory has to be known, and buildable, before deploy. For a hub-and-spoke SEO site (a handful of pillar pages that fan out into thousands of combination pages: service by city, service by industry, service by country), that means generateStaticParams has to return a complete, correct list of every path before the first byte gets written.

Most teams assume the risk in that setup is compute: will 20,000 pages time out the build, will memory blow up somewhere around page 8,000. Those are real failure modes, but they're the easy ones to catch, because a build that runs out of memory or time fails loudly. It shows up in red text in a CI log, someone gets paged, and the fix is usually mechanical: bump the runner's memory allocation, or split the build into batches. The failure mode that actually costs a team weeks is quieter: a build that finishes successfully, deploys cleanly, and serves a 404 for a chunk of the page inventory that nobody notices until an SEO crawl report flags a gap months later, by which point the pages in question may never have been indexed at all.

generateStaticParams and the build-time cost curve

generateStaticParams isn't free per call. For a nested dynamic route like `/[industry]/[country]/[service]`, the function at each segment level typically needs to know about the segments above it to generate the correct combinations, which means either passing context down through nested generateStaticParams calls or computing the full cross-product once and slicing it. The second approach is almost always the right one at scale: build the full list of valid combinations as a single in-memory pass, then let each route file's generateStaticParams filter or map from that one list rather than recomputing a query or a database lookup once per parent segment.

The build-time cost of this approach doesn't scale linearly with the parent segment count if you get the computation right. It does scale linearly, or worse, if each nested generateStaticParams call re-does the expensive part of the discovery process (a database query, a JSON parse of a large file, a network fetch) instead of computing it once and reusing it. This is the single most common performance bug in large App Router builds: correct logic, wrong caching boundary. A build that takes forty minutes because of this pattern will often drop to under ten once the redundant work is found and hoisted to a single module-level computation, with no other code changing at all.

Memory: what a data loader should never do

The other place teams get surprised is the data loader shape. If your generateStaticParams and your page component both independently load "all services" or "all locations" from a JSON file or a database, you'll load that dataset once per route being built, not once total. At 500 pages this is invisible. At 50,000 pages it's the difference between a five-minute build and one that gets killed by the CI runner's memory limit, usually with an error message vague enough that the actual cause takes an afternoon to track down.

The fix is boring and it's the same fix every time: load the shared dataset exactly once, at module scope, and let every function that needs it read from the same reference in memory rather than re-fetching or re-parsing it. Node's module cache does most of this work for you automatically if the loading code lives in its own module and gets imported, rather than being duplicated inline in each route file. The mistake isn't usually a lack of caching logic; it's that nobody realized two files were quietly doing the same expensive work twice, because each file looked correct in isolation, and code review at the individual file level has no way to catch a duplication that only becomes visible when you look across files.

The routing bug nobody catches until page 4,000

Here's the lesson that's actually worth writing down, because it's a class of bug rather than a one-off mistake: Next.js's file-system router resolves matches by specificity, and a literal (non-bracketed) segment at a given path level takes priority over a dynamic segment at that same level. That's usually exactly what you want. It becomes a problem the moment your page inventory includes both a small number of hand-authored, literal routes (a hub page, a features page, a handful of named landing pages) and a much larger set of dynamic combination routes generated from data, sitting at the same directory depth.

If a literal route and a dynamic route claim the same URL shape, and your generateStaticParams for the dynamic segment doesn't know to exclude the literal one, one of two things happens. Either the literal page silently wins and the dynamic route for that specific path never renders, which is harmless if unintentional, or worse, the dynamic route's generateStaticParams includes a path that the literal route was actually supposed to own, and depending on build order, the literal route gets shadowed instead. With dynamicParams set to false, which is the standard, correct setting for a fully static export, there's no server-side fallback to catch the mismatch at request time. A path that isn't in the enumerated list simply doesn't exist. It 404s, and it does so silently: no build warning, no failed check, because as far as the build is concerned every path it was told to generate, it generated successfully.

The reason this doesn't show up in testing is that testing tends to sample. Nobody manually clicks through 40,000 generated URLs before a deploy. The gap gets discovered by an SEO crawler weeks later reporting an unexplained spike in 404s, or by a customer forwarding a broken link, at which point tracing it back to "a literal-prefix segment and a dynamic segment collided at the same route depth" takes real archaeology, because the build logs show nothing wrong and the deploy dashboard shows a clean, green build.

What we'd tell a team starting this today

Treat routing-segment collisions as a build-time check, not a code-review judgment call. Before shipping a large dynamic route tree, we generate the full list of paths generateStaticParams will produce and diff it against the list of literal, hand-authored routes in the same directory tree. If there's any overlap, that's a bug waiting to happen, not a coincidence to shrug off, and it needs to fail the build, loudly, the same way a memory error would.

A short list of what that check should actually cover:

  • The full generated path list, diffed against every literal route file in the same directory tree, run as a CI step rather than a manual review.
  • A sample crawl of a meaningful percentage of generated pages post-deploy, not just the pillar pages, checking for unexpected 404 or redirect status codes.
  • A single, shared data-loading module per dataset, imported everywhere it's needed, rather than the same fetch or parse logic copied into multiple route files.
  • Build-time logging of how many paths generateStaticParams actually returned per route segment, checked against the expected count, so a silent undercount gets caught the same day it happens rather than months later.

The broader principle generalizes past Next.js: any system where routing decisions are made by matching against both a small set of explicit rules and a large set of generated ones needs an explicit conflict check, because "the more specific rule wins" is a fine default until two different parts of your codebase both think they own the more specific rule. Catch that class of bug at build time, and the actual scaling problem, the one everyone expects, memory and compute, turns out to be the easier half of the work.

Rohan

Written by

Rohan

Co-Founder & Cloud / DevOps Engineer

Architects scalable cloud infrastructure on AWS and GCP, CI/CD pipelines, and security-first deployment environments.

Want to build software that scales?

Book a free 30-minute discovery call