I run TextTimeTools, a free site with speaking-time and reading-time calculators. It's a pure static site deployed to Cloudflare Pages — no backend, no database, no CMS.
The calculators themselves are one page. But the site has 50+ pages, each targeting a different keyword like "how many words is a 5 minute speech" or "how long to read 1000 words". Every single one of those pages is generated by a build script. I've never written one by hand.
Here's the pattern, and why it's the highest-leverage thing I've done for this site's organic traffic.
The problem with a single calculator page
A speaking-time calculator answers one query well: "how many words is my speech". But people don't search for tools — they search for answers:
- "how many words is a 5 minute speech"
- "how many words for a 3 minute speech"
- "how long to read 1000 words"
- "how long to read 5000 words"
Each of those is a separate keyword with its own search intent and its own competition. One calculator page can't rank for all of them — a page titled "Speaking Time Calculator" has no reason to show up for "how long to read 1000 words".
The classic fix is to write a page per keyword. That works, but it doesn't scale — every new keyword means hand-writing another page, and keeping them consistent is a nightmare.
The fix: generate pages at build time
The build script (gen-longtail.cjs) takes a list of keyword targets and emits a complete, keyword-specific HTML page for each one. The word count pages and reading time pages are both generated this way.
const PAGES = [
{ minutes: 1, slug: 'how-many-words-is-a-1-minute-speech', variant: 'is-a' },
{ minutes: 2, slug: 'how-many-words-is-a-2-minute-speech', variant: 'is-a' },
{ minutes: 5, slug: 'how-many-words-is-a-5-minute-speech', variant: 'is-a' },
// ... up to 15 minutes
{ minutes: 2, slug: 'how-many-words-for-a-2-minute-speech', variant: 'for-a' },
{ minutes: 5, slug: 'how-many-words-for-a-5-minute-speech', variant: 'for-a' },
];
For each entry, the script computes the answer from a speed table, then renders a full HTML page with:
- A keyword-exact
<title>and<h1>("How Many Words Is a 5 Minute Speech?") - The answer computed in the page content (e.g., "at 130 WPM, a 5-minute speech is about 650 words")
- A speed selector table with the full calculation
- FAQ section with schema markup, each Q&A pre-written for that keyword
The page body is not templated filler — the numbers are real, computed from the same logic the calculator uses, so the answer is always consistent across the site.
The build pipeline
The whole thing runs on npm run build:
{
"scripts": {
"gen:longtail": "node scripts/gen-longtail.cjs",
"gen:reading": "node scripts/gen-reading.cjs",
"build": "npm run gen:longtail && npm run gen:reading && node scripts/build-site.mjs && node scripts/gen-sitemap.mjs",
"deploy": "npm run build && wrangler pages deploy dist --project-name texttimetools --branch main"
}
}
Four steps, in order:
-
gen-longtail.cjs— generates the "how many words is a X minute speech" pages -
gen-reading.cjs— generates the "how long to read N words" pages -
build-site.mjs— assemblesdist/, copying only the public files plus any auto-discovered generated page directories -
gen-sitemap.mjs— reads the actualdist/contents and writessitemap.xml
The sitemap step is the sneaky-important one. Instead of maintaining a URL list by hand, it discovers the pages that actually exist after the build:
const prefixes = ['how-many-words-', 'how-long-to-read-'];
for (const entry of readdirSync(DIST)) {
if (!prefixes.some(p => entry.startsWith(p))) continue;
if (!existsSync(join(DIST, entry, 'index.html'))) continue;
urls.push({ loc: `${SITE}/${entry}/`, priority: '0.7', freq: 'monthly' });
}
If a generator stops producing a page, it automatically drops out of the sitemap. No orphaned URLs, no manual sync.
Why this works
1. Each page is actually good
Generated doesn't mean thin. Every page has a real computed answer, a real FAQ, and schema markup. They're as useful as a hand-written page — because the template was hand-written once, with care, and the data is real.
2. Adding a keyword is a one-line change
Want to target "how many words is a 12 minute speech"? Add one object to the array, rebuild, deploy. The page, sitemap entry, and internal links all update automatically.
3. Consistency is guaranteed
Every page uses the same WPM table and the same calculation. A reader who lands on the 5-minute page and the 10-minute page gets answers that agree — which builds trust and reduces bounce.
4. The sitemap can't lie
Because it's generated from what's actually in dist/, the sitemap always matches reality. This is the kind of thing that quietly breaks on hand-maintained sites (page deleted, sitemap stale) and wastes crawl budget.
Practical notes
-
Keyword research comes first. Each entry in the
PAGESarray is a keyword with measured search volume. The generator is a factory, but the what still comes from research. - Numbers should match the tool. The page answers and the calculator use the same formulas, so a visitor can verify the answer interactively. That combination — instant answer + interactive tool — is what makes a landing page convert.
- Keep the template in one place. When I update the WPM table or the FAQ copy, every generated page picks it up on the next build. That's the whole point.
The results
This pattern took the site from one page to 50+ keyword-targeted pages with maybe two hours of script-writing and zero ongoing manual work. It's the difference between "I have a calculator" and "I rank for the questions people actually ask."
The live site is at texttimetools.com — all pages generated, all static, all free.
If you have a tool that answers a family of questions, this pattern is worth stealing. Have you generated SEO pages from build scripts? I'd love to hear what worked and what didn't in the comments.
Top comments (0)