· via dev.to (home feed)
Next.js App Router silently skipped 1,200 generated pages because of a partial dynamic segment
A dev.to postmortem explains how a folder named factors-of-[number] produced one static page instead of 1,200, because App Router only treats fully bracketed segments as dynamic.

A build that succeeded while producing one page
A developer has published a postmortem on dev.to describing how Next.js 16's App Router silently discarded the 1,200 generated pages in a side project. The site, factorcalculator.org, is a math tool that shows factors, factor pairs, prime factorizations and divisors for any number, built with the App Router, Tailwind and static export, and deployed to Cloudflare Pages. Six calculator pages were hand-built; the remaining 1,200 were generated routes running from /factors-of-1/ through /factors-of-1200/.
To get URLs like /factors-of-84/, the author named the route folder factors-of-[number]. According to the post, the build completed with no error and no warning, and emitted exactly one page: a literal static route at the URL-encoded path /factors-of-%5Bnumber%5D/. The generateStaticParams function did not return an empty set; it never executed at all.
The cause is a rule of the App Router: a path segment is dynamic only when the entire segment is a bracket expression. [slug] is dynamic. factors-of-[number] is merely a folder whose name happens to contain brackets, so the router registers an ordinary static route with an unusual name. From the router's point of view, there is nothing to warn about.
Parsing the slug yourself
The fix was to collapse the route into a single dynamic segment at the root of the app directory, [slug], and parse the prefix in application code. The author's parser accepts strings consisting of the factors-of- prefix followed by digits, rejects leading zeros, and validates that the number is an integer between 1 and 1200. generateStaticParams then enumerates all 1,200 slugs up front.
Three details from the post are worth noting:
- Static routes take precedence over dynamic ones, so a root-level [slug] does not swallow hand-built pages such as a standalone calculator route; it only receives whatever the static routes leave unmatched.
- Setting dynamicParams to false turns anything outside generateStaticParams into a 404. Without it, the dynamic segment would happily attempt on-demand rendering of arbitrary URLs in what is meant to be a finite, known URL space.
- Leading zeros are rejected so that /factors-of-012/ cannot serve a duplicate of /factors-of-12/. In the author's words, that guard is trivial to add up front and painful to remediate after crawlers have indexed the variants.
A hydration mismatch that kept returning
A second problem involved the interactive calculator: the server-rendered and client-rendered HTML disagreed. The first culprit was locale-dependent formatting, since toLocaleString can produce "1,234" on the server and a different digit grouping in some browser locales, yielding two different strings across the hydration boundary.
Swapping in a deterministic, regex-based number formatter fixed that symptom, but mismatches resurfaced elsewhere as the component grew. The eventual fix was structural: render a static skeleton on the server and the first client render so both are byte-identical, then swap in the real widget after mount. The author acknowledges the trade-off, since users and crawlers without JavaScript never see the tool, and considers it acceptable because the substantive content on every number page is server-rendered.
The post also identifies browser extensions that inject attributes onto the body element as a recurring source of spurious hydration warnings, quieted with suppressHydrationWarning.
Computed content instead of templates
For the generated pages themselves, the author avoided template-plus-variable copy and language-model text, noting that search engines increasingly demote near-identical programmatic pages. Instead, each page's content is computed from the number's actual mathematical properties: divisor count, divisor sum, primality, whether it is a perfect square, highly composite, triangular, a Fibonacci number or a prime power, and its abundance classification. The prose varies by those properties, so a perfect number, a highly composite number and a perfect square each get different commentary without anyone writing page-specific text by hand.
One small constraint surfaced along the way: the root layout appends a suffix to every title through the Metadata API, and with search results truncating titles around 60 characters, each page title has a working budget of roughly 40 characters.
Finally, the author wrote about 200 lines of validation scripts that run against the built output directory before every deploy, on the grounds that nobody can manually review 1,200 pages.
Why it matters
The failure mode here is the dangerous kind: a clean build, a healthy-looking deploy, and 1,200 pages that simply do not exist. Nothing in the toolchain distinguishes "I created a dynamic route" from "I created a static route with brackets in its name"; only inspecting the generated output reveals the difference. The post also doubles as a reusable pattern for finite programmatic URL spaces in the App Router: one dynamic segment, parsing in code, dynamicParams disabled, and guards against duplicate-content variants. And it stands as a reminder that locale-dependent formatting remains a standing hydration hazard in any pre-rendered React application.
- #next-js
- #react
- #hydration
- #seo
- #static-site-generation