I spent about a week building factorcalculator.org — a free math tool that finds the factors, factor pairs, prime factorization and divisors of any number, with the full working shown instead of just an answer.
Six hand-built calculator pages, plus 1,200 generated pages (/factors-of-1/ through /factors-of-1200/). Next.js 16 App Router, Tailwind, static export, deployed to Cloudflare Pages.
Most of it went fine. Three things did not, and two of them are the kind of bug where everything looks like it's working right up until it very obviously isn't. Here they are, plus an honest postscript about what the traffic actually did — because I've read a lot of programmatic SEO posts that stop at "and then I deployed it," and that turned out to be the least interesting part. The App Router does not support partial dynamic segments
It builds. No error, no warning. And it produces exactly one page: a literal static route at the URL /factors-of-%5Bnumber%5D/. My generateStaticParams never ran. Not "ran and returned nothing" — never ran at all.
The App Router only treats a path segment as dynamic when the entire segment is a bracket expression. [slug] is dynamic. factors-of-[number] is a folder whose name happens to contain brackets. There's no error for this because, as far as the router is concerned, you made a static route with an unusual name.
Static routes still win. Putting [slug] at the root of app/ felt dangerous — wouldn't it swallow /gcf-calculator/? It doesn't. Next.js matches static segments before dynamic ones, so every hand-built page keeps its own route and [slug] only sees what's left over.
dynamicParams = false is doing real work. Without it, a dynamic segment will happily try to render /factors-of-99999999/ on demand. With it, anything outside generateStaticParams is a 404 — which is what you want when your URL space is supposed to be finite and known.
The leading-zero guard is the sort of thing that's invisible until a crawler finds it. /factors-of-012/, /factors-of-0012/ and so on are infinite variants of a page that already exists. Cheap to block, expensive to clean up later. The hydration mismatch that kept coming back
The calculator has an input and a result. Server render and client render disagreed, and I fixed it three separate times before I fixed it properly.
Anything locale-dependent — toLocaleString, Intl.NumberFormat, new Date(), Math.random() — is a hydration hazard in a pre-rendered page, because the server and the browser don't necessarily agree. I replaced it with something deterministic:
But the mismatch kept reappearing in other places as the component grew. The real fix was to stop trying to make the server and client render match for an interactive widget, and instead guarantee they match by rendering nothing interactive until after mount:
The skeleton is a static placeholder, so there is nothing to mismatch. React hydrates it cleanly, the effect fires, and the interactive version swaps in.
The trade-off is real and worth stating: the tool is invisible to anything that doesn't execute JavaScript. That was acceptable here because the content — the factor list, the pairs, the prime factorization, the tree — is server-rendered on every number page. Only the interactive input is gated. If the calculator itself were the content, I'd have solved it differently.
One last piece of noise: browser extensions inject attributes onto (cz-shortcut-listen, data-sharkid and friends) and React complains. That's not your bug: Generating 1,200 pages that aren't filler
The easy version of programmatic SEO is a template with a variable in it. That produces 1,200 pages that are 98% identical, which is both a bad experience and, increasingly, something Google actively demotes.
So none of the page copy is written by a language model or pulled from a spintax list. It's all computed from the number's actual mathematical properties:
The prose layer then writes about those facts. A prime gets a different paragraph from a highly composite number, which gets a different paragraph from a perfect square. 36 gets a note about having an odd number of divisors because it's a perfect square. 28 gets a note about being a perfect number. 1,200 gets a note about being highly composite.
Emergent, not templated. And it means the differentiation scales for free — I never wrote a paragraph about 847 specifically, but 847's page says true and specific things about 847.
