DEV Community

Cover image for Next.js App Router silently ignored my 1,200 generated pages. Here's what I got wrong.
naveed99991
naveed99991

Posted on

Next.js App Router silently ignored my 1,200 generated pages. Here's what I got wrong.

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.


1. The App Router does not support partial dynamic segments

This is the one that cost me most of a day.

I wanted URLs like /factors-of-84/. The obvious folder name seemed to be:

app/
  factors-of-[number]/
    page.js
Enter fullscreen mode Exit fullscreen mode

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.

The fix is to take the whole segment and parse the prefix yourself:

// app/[slug]/page.js
import { notFound } from 'next/navigation';

export const dynamicParams = false; // anything not in generateStaticParams -> 404

const MAX = 1200;

function parseSlug(slug) {
  const match = /^factors-of-(\d+)$/.exec(slug);
  if (!match) return null;

  const raw = match[1];

  // /factors-of-012/ would otherwise render the same page as
  // /factors-of-12/ -- duplicate content on a silver platter.
  if (raw.length > 1 && raw.startsWith('0')) return null;

  const n = Number(raw);
  if (!Number.isInteger(n) || n < 1 || n > MAX) return null;

  return n;
}

export function generateStaticParams() {
  return Array.from({ length: MAX }, (_, i) => ({
    slug: `factors-of-${i + 1}`,
  }));
}

export default async function Page({ params }) {
  const { slug } = await params;
  const n = parseSlug(slug);
  if (n === null) notFound();

  return <NumberPageContent n={n} />;
}
Enter fullscreen mode Exit fullscreen mode

Two things worth knowing about this shape:

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.


2. 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.

The first culprit was obvious in hindsight:

// Renders "1,234" on the server and, in some client locales,
// "1 234" or "1.234". Two different strings -> hydration mismatch.
value.toLocaleString()
Enter fullscreen mode Exit fullscreen mode

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:

function formatNumber(n) {
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
Enter fullscreen mode Exit fullscreen mode

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:

export default function FactorCalculator() {
  const [mounted, setMounted] = useState(false);
  const [input, setInput] = useState('12');

  useEffect(() => setMounted(true), []);

  // Server HTML and first client render are byte-identical.
  if (!mounted) return <CalculatorSkeleton />;

  return ( /* the real thing */ );
}
Enter fullscreen mode Exit fullscreen mode

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 <body> (cz-shortcut-listen, data-sharkid and friends) and React complains. That's not your bug:

<body suppressHydrationWarning>
Enter fullscreen mode Exit fullscreen mode

3. 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:

export function buildNumberFacts(n) {
  const factors = getFactors(n);
  const primes = getPrimeFactorization(n);

  return {
    n,
    factors,
    pairs: getFactorPairs(n),
    primes,
    divisorCount: factors.length,                    // d(n)
    divisorSum: factors.reduce((a, b) => a + b, 0),  // sigma(n)
    isPrime: factors.length === 2,
    isPerfectSquare: Number.isInteger(Math.sqrt(n)),
    isHighlyComposite: isHighlyComposite(n),
    isTriangular: isTriangular(n),
    isFibonacci: isFibonacci(n),
    isPrimePower: primes.length === 1,
    classification: classifyAbundance(n), // perfect / abundant / deficient
  };
}
Enter fullscreen mode Exit fullscreen mode

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.

A small but annoying constraint: my root layout appends " | Factor Calculator" 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.


4. You cannot eyeball 1,200 pages

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

validate-content.js checks that every page is genuinely distinct and self-consistent:

  • unique <title>, unique meta description, unique body content hash
  • exactly one <h1> per page
  • canonical present and matching the page's own URL
  • factor lists spot-checked against an independent brute-force implementation — not the library that generated them

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

validate-structure.js checks the site as a graph:

  • every internal link resolves to a page that exists
  • every JSON-LD block parses
  • every image has alt text
  • every page appears in the sitemap
  • no orphans — every page is reachable from somewhere other than the sitemap

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.

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

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


5. Moving to Cloudflare Pages via static export

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.

// next.config.js
const nextConfig = {
  output: 'export',
  trailingSlash: true,
  images: { unoptimized: true },
};
Enter fullscreen mode Exit fullscreen mode

Three things bit me:

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

Metadata routes need an explicit opt-in. app/sitemap.js, app/robots.js and app/manifest.js each fail the export build until you add:

export const dynamic = 'force-static';
Enter fullscreen mode Exit fullscreen mode

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

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

Final build: 1,218 static pages, 7,323 files, ~12 seconds with Turbopack.

pnpm build && pnpm dlx wrangler pages deploy out \
  --project-name=factorcalculator --branch=main --commit-dirty=true
Enter fullscreen mode Exit fullscreen mode

6. The honest part: what the traffic actually did

Here's the section most posts like this leave out.

The site went live at the end of August. Google indexed it within 24 hours. Then this happened:

Date Clicks Impressions Avg. position
Sep 9 87 270,445 8.4
Sep 10 80 211,063 8.6
Sep 11 61 262,017 8.6
Sep 12 56 192,071 7.8
Sep 13 12 30,582 6.6
Sep 16 0 75 32.4
Sep 18 0 58 28.7

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 X-Robots-Tag.

I hadn't broken anything. The migration was three days after the drop. No manual action, no security issue, 934 pages still indexed.

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

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.

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.

If you launch a programmatic site and see a huge spike in week one, check impressions against the keyword's actual search volume before you celebrate. 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.


What I'd do differently

Build fewer pages. 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 /factors-of-10/, 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.

Earn links before scaling content. 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.

Keep the production build in the loop. The dev server is not a build. See section 4.

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.

The tool is at factorcalculator.org 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.

Top comments (0)