The project
A while back I started Hacé Cuentas, a calculator site for the Spanish-speaking market — taxes, salaries, health, finance, cooking, whatever people google. It's now ~3,400 calculators in three languages (es/en/pt), all statically rendered, on Cloudflare Pages. One repo, one Astro project.
Here's what scaling a content site past a few thousand pages actually taught me — including the wall I hit at ~64 MiB that nearly killed a deploy without telling me.
One route, thousands of pages
Every calculator is two files:
src/content/calcs/.json — content: title, intro, FAQ, SEO metadata, input fields.
src/lib/formulas/.ts — logic: a pure function (inputs) => result.
Splitting content from logic is the decision that paid off most. Content edits never risk breaking math, the formulas are unit-testable in isolation, and — as you'll see below — the JSON's modification time becomes a clean freshness signal for SEO.
A single dynamic route renders all of them:
// src/pages/[...slug].astro
export async function getStaticPaths() {
const calcs = await getCollection('calcs');
return calcs.map((calc) => ({
params: { slug: calc.slug },
props: { calc },
}));
}
Astro content collections validate every JSON against a Zod schema at build time. That schema once caught 25 malformed files before they ever shipped — at this volume, build-time validation isn't optional.
The 64 MiB wall
Cloudflare Pages runs SSR/functions on Workers, and the bundled Worker has a hard size ceiling. I blew past it (~64 MiB) by importing big lookup data into the SSR path. The nasty part: the deploy reported success. wrangler returned OK, but the edge silently kept serving the old worker. I "deployed" three times wondering why nothing changed.
Lesson: at scale, "deploy succeeded" ≠ "it's live." Always verify the live edge content, not the CLI log. The fix was keeping heavy data out of the worker bundle and pre-rendering everything possible to static HTML.
Build OOM (exit 137)
Building 3,400 pages × 3 languages OOM-kills Node. The counterintuitive bit: cranking the heap can make it worse, because the OS OOM-killer steps in. What actually stabilized it was NODE_OPTIONS=--max-old-space-size=8192 plus reducing concurrent memory pressure during the build (don't run a content sweep and the build at once — they collide).
node:fs silently breaks SSR on workerd
Cloudflare's runtime (workerd) is not Node. Importing node:fs in an Astro page compiles fine and explodes at the edge. Worse: astro dev serves from a Node server, so it does not reproduce workerd — some bugs only appear in production. I now verify anything filesystem-adjacent (OG images, etc.) on prod, never trusting dev.
SEO at scale: don't churn your sitemap
The least obvious lesson, and the most important at this size. With thousands of URLs, lastmod is a signal Google trusts — until you abuse it. Regenerate lastmod = today for all 3,400 URLs on every deploy and Google burns crawl budget re-checking unchanged pages, then starts ignoring the signal entirely.
So I compute it from the content itself:
lastmod = max(lastReviewed, dataUpdate.lastUpdated, mtimeOf(jsonFile))
A URL's lastmod only moves when its real content changes. I can deploy 10× a day and the sitemap stays still unless I actually edited something. Your sitemap is a budget you spend — don't waste it on cosmetic diffs.
Bonus: charts without crawl waste
Each formula can return an output._chart (donut/gauge/scale) rendered client-side with Chart.js. Because that config lives in the .ts formula, not the content JSON, adding charts to hundreds of calculators moved exactly zero lastmod values. New feature, no sitemap churn.
Takeaways
Separate content from logic — it compounds at scale.
Trust the platform's real runtime, not your dev server.
"Deploy succeeded" ≠ "live and correct." Verify the edge.
Your sitemap is crawl budget. Spend it only on real changes.
Live site is https://hacecuentas.com if you want to see ~3,400 of these in the wild. Happy to dig into any Astro/Cloudflare detail in the comments.
Top comments (1)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.