Build1 publisher2 min readPublished
A bracket inside a folder name turned 1,200 Next.js pages into one encoded route
The App Router built a static route named after its own placeholder, so a generated set of 1,200 pages never reached the output and nothing in the build complained. The developer lost most of a day to it.
The Engineer · Build desk

What happened
- A developer built factorcalculator.org on Next.js 16's App Router with six hand-written calculator pages and 1,200 generated pages at /factors-of-1/ through /factors-of-1200/, static exported to Cloudflare Pages.
- generateStaticParams never ran at all, and the build shipped a single literal static route at /factors-of-%5Bnumber%5D/ without an error or a warning.
- The App Router counts a path segment as dynamic only when the whole segment is a bracket expression, so factors-of-[number] is read as a static folder whose name happens to contain brackets.
- The fix moves the parameter to app/[slug]/page.js, parses the factors-of- prefix with a regex in the page itself, and calls notFound() for a leading zero or a number outside 1 to 1200.
- Setting dynamicParams to false confines the route to the slugs generateStaticParams returns, where the default would let the segment render /factors-of-99999999/ on demand.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure The loss shows up only where someone counts routes, because a build that emits one page instead of a generated set finishes the same way a correct one does.
- decision Anyone who wants a readable prefix in the URL now owns the parsing: the prefix lives in a regex inside the page, and the directory tree stops describing the URL space.
- cost The leading-zero guard costs one clause in a regex at build time and heads off an unbounded family of duplicate pages that would otherwise have to be cleaned up after a crawler finds them.
- constraint Guaranteeing byte-identical first renders means the interactive part of a pre-rendered page arrives as a placeholder, so whatever the widget computes is not in the HTML that ships.
Next.js reads each path segment as one token. Brackets around the whole segment make a parameter. Brackets inside a longer name make a name, and that name went out percent-encoded as /factors-of-%5Bnumber%5D/ [3]. generateStaticParams only has a call site when its page sits inside a dynamic segment, and here it did not [4][5]. The router did what it was told, which is the least satisfying way to be correct. "There's no error for this because, as far as the router is concerned, you made a static route with an unusual name," the developer wrote on dev.to [6].
The intended output was 1,206 routes, six hand-written and 1,200 generated [1]. What got built was seven: the six static pages, plus the single encoded literal [1][3]. The export directory was 1,199 pages short [18].
Moving the parameter up to the root of app/ looks like the worse idea, because a bare [slug] at the top level appears to claim every URL, including the hand-written /gcf-calculator/. It does not. Next.js matches static segments before dynamic ones, so each hand-built page keeps its own route and the parameter receives only what nothing else claimed [10].
The hydration bug took longer. "Server render and client render disagreed, and I fixed it three separate times before I fixed it properly," the developer wrote [15]. The first cause was value.toLocaleString(). It renders 1234 as "1,234" on the server and as "1 234" or "1.234" in some client locales [12]. A deterministic formatter built on String(n) and a regex replaced it [14]. The mismatch reappeared elsewhere as the component grew, and the fix that held was a mount gate: a useState flag set in useEffect, with a static CalculatorSkeleton returned until it flips, so the server HTML and the first client render are byte-identical [15][16].
That hazard list is the part most likely to transfer to someone else's build, and it transfers to any pre-rendered page rather than only to a generated set: toLocaleString, Intl.NumberFormat, new Date() and Math.random() all read state the build machine and the browser do not share [13]. The write-up counts three problems and promises a postscript on what the traffic did; the text ends mid-sentence in the hydration section, without the third problem or the traffic numbers [17].
What to watch
- A Next.js build warning for bracket characters in a segment name that is not itself a bracket expression would close this failure mode at the source.
- The traffic postscript the developer promised would show whether the 1,200 recovered pages were indexed once they existed as files.
- The third problem the write-up counts is not in the published text, and may or may not be specific to static export.