<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: naveed99991</title>
    <description>The latest articles on DEV Community by naveed99991 (@naveed99991).</description>
    <link>https://dev.to/naveed99991</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4135188%2F03d6fcf2-95e6-4e3e-b7bd-6d03b4d51fa9.png</url>
      <title>DEV Community: naveed99991</title>
      <link>https://dev.to/naveed99991</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/naveed99991"/>
    <language>en</language>
    <item>
      <title>Next.js App Router silently ignored my 1,200 generated pages. Here's what I got wrong.</title>
      <dc:creator>naveed99991</dc:creator>
      <pubDate>Mon, 21 Sep 2026 07:30:27 +0000</pubDate>
      <link>https://dev.to/naveed99991/nextjs-app-router-silently-ignored-my-1200-generated-pages-heres-what-i-got-wrong-4dnm</link>
      <guid>https://dev.to/naveed99991/nextjs-app-router-silently-ignored-my-1200-generated-pages-heres-what-i-got-wrong-4dnm</guid>
      <description>&lt;p&gt;I spent about a week building &lt;a href="https://factorcalculator.org" rel="noopener noreferrer"&gt;factorcalculator.org&lt;/a&gt; — 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.&lt;/p&gt;

&lt;p&gt;Six hand-built calculator pages, plus 1,200 generated pages (&lt;code&gt;/factors-of-1/&lt;/code&gt; through &lt;code&gt;/factors-of-1200/&lt;/code&gt;). Next.js 16 App Router, Tailwind, static export, deployed to Cloudflare Pages.&lt;/p&gt;

&lt;p&gt;Most of it went fine. Three things did not, and two of them are the kind of bug where everything &lt;em&gt;looks&lt;/em&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. The App Router does not support partial dynamic segments
&lt;/h2&gt;

&lt;p&gt;This is the one that cost me most of a day.&lt;/p&gt;

&lt;p&gt;I wanted URLs like &lt;code&gt;/factors-of-84/&lt;/code&gt;. The obvious folder name seemed to be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
  factors-of-[number]/
    page.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It builds. No error, no warning. And it produces exactly one page: a literal static route at the URL &lt;code&gt;/factors-of-%5Bnumber%5D/&lt;/code&gt;. My &lt;code&gt;generateStaticParams&lt;/code&gt; never ran. Not "ran and returned nothing" — never ran at all.&lt;/p&gt;

&lt;p&gt;The App Router only treats a path segment as dynamic when &lt;strong&gt;the entire segment&lt;/strong&gt; is a bracket expression. &lt;code&gt;[slug]&lt;/code&gt; is dynamic. &lt;code&gt;factors-of-[number]&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;The fix is to take the whole segment and parse the prefix yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/[slug]/page.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;notFound&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamicParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// anything not in generateStaticParams -&amp;gt; 404&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^factors-of-&lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="c1"&gt;// /factors-of-012/ would otherwise render the same page as&lt;/span&gt;
  &lt;span class="c1"&gt;// /factors-of-12/ -- duplicate content on a silver platter.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MAX&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`factors-of-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Page&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;NumberPageContent&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth knowing about this shape:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Static routes still win.&lt;/strong&gt; Putting &lt;code&gt;[slug]&lt;/code&gt; at the root of &lt;code&gt;app/&lt;/code&gt; felt dangerous — wouldn't it swallow &lt;code&gt;/gcf-calculator/&lt;/code&gt;? It doesn't. Next.js matches static segments before dynamic ones, so every hand-built page keeps its own route and &lt;code&gt;[slug]&lt;/code&gt; only sees what's left over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dynamicParams = false&lt;/code&gt; is doing real work.&lt;/strong&gt; Without it, a dynamic segment will happily try to render &lt;code&gt;/factors-of-99999999/&lt;/code&gt; on demand. With it, anything outside &lt;code&gt;generateStaticParams&lt;/code&gt; is a 404 — which is what you want when your URL space is supposed to be finite and known.&lt;/p&gt;

&lt;p&gt;The leading-zero guard is the sort of thing that's invisible until a crawler finds it. &lt;code&gt;/factors-of-012/&lt;/code&gt;, &lt;code&gt;/factors-of-0012/&lt;/code&gt; and so on are infinite variants of a page that already exists. Cheap to block, expensive to clean up later.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. The hydration mismatch that kept coming back
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The first culprit was obvious in hindsight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Renders "1,234" on the server and, in some client locales,&lt;/span&gt;
&lt;span class="c1"&gt;// "1 234" or "1.234". Two different strings -&amp;gt; hydration mismatch.&lt;/span&gt;
&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLocaleString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything locale-dependent — &lt;code&gt;toLocaleString&lt;/code&gt;, &lt;code&gt;Intl.NumberFormat&lt;/code&gt;, &lt;code&gt;new Date()&lt;/code&gt;, &lt;code&gt;Math.random()&lt;/code&gt; — 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;formatNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\B(?=(\d{3})&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;(?!\d))&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FactorCalculator&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMounted&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setInput&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;12&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setMounted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="c1"&gt;// Server HTML and first client render are byte-identical.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;mounted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CalculatorSkeleton&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="cm"&gt;/* the real thing */&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;content&lt;/em&gt; — 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.&lt;/p&gt;

&lt;p&gt;One last piece of noise: browser extensions inject attributes onto &lt;code&gt;&amp;lt;body&amp;gt;&lt;/code&gt; (&lt;code&gt;cz-shortcut-listen&lt;/code&gt;, &lt;code&gt;data-sharkid&lt;/code&gt; and friends) and React complains. That's not your bug:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;body&lt;/span&gt; &lt;span class="na"&gt;suppressHydrationWarning&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  3. Generating 1,200 pages that aren't filler
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildNumberFacts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;factors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getFactors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;primes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getPrimeFactorization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;factors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;pairs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getFactorPairs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;primes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;divisorCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;factors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                    &lt;span class="c1"&gt;// d(n)&lt;/span&gt;
    &lt;span class="na"&gt;divisorSum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;factors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;// sigma(n)&lt;/span&gt;
    &lt;span class="na"&gt;isPrime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;factors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isPerfectSquare&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isInteger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="na"&gt;isHighlyComposite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;isHighlyComposite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;isTriangular&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;isTriangular&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;isFibonacci&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;isFibonacci&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;isPrimePower&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;primes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;classifyAbundance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// perfect / abundant / deficient&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prose layer then writes &lt;em&gt;about those facts&lt;/em&gt;. 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A small but annoying constraint: my root layout appends &lt;code&gt;" | Factor Calculator"&lt;/code&gt; to every title via the Metadata API. That's 20 characters. Google truncates around 60. So the per-page title has a hard budget of ~40 characters before the suffix, which is tighter than it sounds when you're trying to fit a factor list into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. You cannot eyeball 1,200 pages
&lt;/h2&gt;

&lt;p&gt;I wrote two validation scripts that run against the built &lt;code&gt;out/&lt;/code&gt; directory before every deploy. They're maybe 200 lines total and they've caught more real bugs than anything else in the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;validate-content.js&lt;/code&gt;&lt;/strong&gt; checks that every page is genuinely distinct and self-consistent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;unique &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, unique meta description, unique body content hash&lt;/li&gt;
&lt;li&gt;exactly one &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; per page&lt;/li&gt;
&lt;li&gt;canonical present and matching the page's own URL&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;factor lists spot-checked against an independent brute-force implementation&lt;/strong&gt; — not the library that generated them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one matters. Testing &lt;code&gt;getFactors()&lt;/code&gt; against itself proves nothing. The validator re-derives the factors with a dumb &lt;code&gt;for (let i = 1; i &amp;lt;= n; i++)&lt;/code&gt; loop and compares. Slow, obviously correct, and it's the only thing standing between me and 1,200 confidently wrong pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;validate-structure.js&lt;/code&gt;&lt;/strong&gt; checks the site as a graph:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every internal link resolves to a page that exists&lt;/li&gt;
&lt;li&gt;every JSON-LD block parses&lt;/li&gt;
&lt;li&gt;every image has alt text&lt;/li&gt;
&lt;li&gt;every page appears in the sitemap&lt;/li&gt;
&lt;li&gt;no orphans — every page is reachable from somewhere other than the sitemap&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Current output: 1,213 unique titles, 1,213 unique descriptions, 1,213 unique body hashes, 78,279 internal links all resolving, 4,841 JSON-LD blocks parsing, zero orphans.&lt;/p&gt;

&lt;p&gt;It also caught a genuinely nasty one. A paste landed in the wrong component file, which broke &lt;code&gt;pnpm build&lt;/code&gt; with &lt;code&gt;getCommonFactors is not defined&lt;/code&gt; — while &lt;code&gt;pnpm dev&lt;/code&gt; seemed perfectly happy, because the broken path was never hit in dev. Lesson learned: &lt;strong&gt;run the production build after every page, not at the end.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;While I'm listing traps — if you add Babel for Jest, do not name the file &lt;code&gt;babel.config.js&lt;/code&gt;. Next.js detects it, assumes you want Babel, and silently disables SWC. Name it &lt;code&gt;babel.jest.config.js&lt;/code&gt; and point &lt;code&gt;jest.config.js&lt;/code&gt; at it explicitly.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Moving to Cloudflare Pages via static export
&lt;/h2&gt;

&lt;p&gt;The site is 100% pre-rendered: no SSR, no API routes, no middleware. That meant I could skip the OpenNext/Workers adapter entirely and just use Next.js static export — no 3 MiB Worker size limit, works with Turbopack, pure CDN.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;trailingSlash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;unoptimized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things bit me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;headers()&lt;/code&gt; and &lt;code&gt;redirects()&lt;/code&gt; don't run in static export.&lt;/strong&gt; There's no server to run them. They move to &lt;code&gt;public/_headers&lt;/code&gt; and &lt;code&gt;public/_redirects&lt;/code&gt;, which Cloudflare Pages reads directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Metadata routes need an explicit opt-in.&lt;/strong&gt; &lt;code&gt;app/sitemap.js&lt;/code&gt;, &lt;code&gt;app/robots.js&lt;/code&gt; and &lt;code&gt;app/manifest.js&lt;/code&gt; each fail the export build until you add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;force-static&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error message (&lt;code&gt;export const dynamic = force-static not configured on route&lt;/code&gt;) is at least honest about the fix, but it's easy to miss that it applies to all three.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS is the opposite of what Vercel wanted.&lt;/strong&gt; On Vercel I needed grey-cloud (DNS-only) records to avoid a redirect loop. On Cloudflare Pages both apex and &lt;code&gt;www&lt;/code&gt; should be &lt;strong&gt;proxied&lt;/strong&gt; (orange cloud). And adding the DNS record is not enough — &lt;code&gt;www&lt;/code&gt; returned 522 until I also added &lt;code&gt;www.factorcalculator.org&lt;/code&gt; under &lt;strong&gt;Pages -&amp;gt; Custom domains&lt;/strong&gt;. Pages has to know the hostname, not just DNS.&lt;/p&gt;

&lt;p&gt;Final build: 1,218 static pages, 7,323 files, ~12 seconds with Turbopack.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm dlx wrangler pages deploy out &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project-name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;factorcalculator &lt;span class="nt"&gt;--branch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;main &lt;span class="nt"&gt;--commit-dirty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  6. The honest part: what the traffic actually did
&lt;/h2&gt;

&lt;p&gt;Here's the section most posts like this leave out.&lt;/p&gt;

&lt;p&gt;The site went live at the end of August. Google indexed it within 24 hours. Then this happened:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Date&lt;/th&gt;
&lt;th&gt;Clicks&lt;/th&gt;
&lt;th&gt;Impressions&lt;/th&gt;
&lt;th&gt;Avg. position&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sep 9&lt;/td&gt;
&lt;td&gt;87&lt;/td&gt;
&lt;td&gt;270,445&lt;/td&gt;
&lt;td&gt;8.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 10&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;211,063&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 11&lt;/td&gt;
&lt;td&gt;61&lt;/td&gt;
&lt;td&gt;262,017&lt;/td&gt;
&lt;td&gt;8.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 12&lt;/td&gt;
&lt;td&gt;56&lt;/td&gt;
&lt;td&gt;192,071&lt;/td&gt;
&lt;td&gt;7.8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sep 13&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;12&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;30,582&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;6.6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 16&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;32.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sep 18&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;58&lt;/td&gt;
&lt;td&gt;28.7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Overnight, a 99.97% drop. My first instinct was that I'd broken something — I'd migrated to Cloudflare around then, and I spent a while hunting for a stray &lt;code&gt;X-Robots-Tag&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I hadn't broken anything. The migration was three days &lt;em&gt;after&lt;/em&gt; the drop. No manual action, no security issue, 934 pages still indexed.&lt;/p&gt;

&lt;p&gt;What actually happened is visible in the ratio. Over three weeks: &lt;strong&gt;2,087,587 impressions and 814 clicks on a single query.&lt;/strong&gt; That's a 0.039% clickthrough rate at an average position of 7.6, where the normal rate would be around 5%.&lt;/p&gt;

&lt;p&gt;And the arithmetic doesn't work at all. That query gets roughly 201,000 searches a month — about 6,700 a day. Google reported 270,445 impressions for it in one day. Forty times the entire daily search volume for the term.&lt;/p&gt;

&lt;p&gt;Those were never real rankings. Google gave a brand-new exact-match domain a very large trial run, measured what users did with it, got a rounding-error clickthrough rate back, and closed the trial on September 13. Position 25-32 is where the site actually sits: a three-week-old domain with zero backlinks.&lt;/p&gt;

&lt;p&gt;If you launch a programmatic site and see a huge spike in week one, &lt;strong&gt;check impressions against the keyword's actual search volume before you celebrate.&lt;/strong&gt; If the impressions exceed the search volume, you're looking at a test, not a ranking. Plan for the number after the test, not the number during it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Build fewer pages.&lt;/strong&gt; 1,200 was ambition, not strategy. 278 of them have never been crawled — Google found them in the sitemap and declined to spend the budget. The URLs sitting uncrawled include &lt;code&gt;/factors-of-10/&lt;/code&gt;, which is not an obscure number. Crawl budget scales with authority, and a new domain doesn't have any. 300 good pages would have been indexed faster and looked less like scaled content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Earn links before scaling content.&lt;/strong&gt; Every symptom I have — position 25-32, no crawl budget, uncrawled pages — is one root cause wearing different hats. I optimized the thing I could control from my editor and deferred the thing that requires talking to people. Wrong order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep the production build in the loop.&lt;/strong&gt; The dev server is not a build. See section 4.&lt;/p&gt;

&lt;p&gt;The routing lesson in section 1 is the one I'd have paid to know in advance, so if this post saves you that day, it's done its job.&lt;/p&gt;

&lt;p&gt;The tool is at &lt;a href="https://factorcalculator.org" rel="noopener noreferrer"&gt;factorcalculator.org&lt;/a&gt; if you want to poke at it — factor trees, factor pairs and the full Euclidean working for GCF are the parts I'm happiest with.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
