Multilingual static sites break in a boring, expensive way: the canonical tag says one URL, the hreflang alternates say a slightly different one, and the sitemap says a third. Google resolves the contradiction by ignoring your signals and picking whichever version it likes. You do not get an error. You get flat traffic and a "Duplicate, Google chose a different canonical" line in Search Console months later.
This is a write-up of a static generator — one file, no dependencies, no client JavaScript — built around two constraints: Every URL the site emits comes out of a single function. A page that does not clear a content-length threshold is not allowed to be indexable, and the build enforces it.
The usual setup is not careless. It is that URL construction is distributed. The canonical tag is written in a layout template. The hreflang block is generated in an i18n helper. The sitemap is emitted by a plugin. The nav links are written by hand. Four independent pieces of code each build "the URL for page X in language Y."
Each will be correct in isolation and they will still drift, because the interesting cases are the edges: trailing slash or not, does the default language live at / or /en/, is x-default a separate entity, does a page that only exists in some languages still emit alternates for all of them. Every one of those questions has to be answered identically in four places, and nothing checks that they were.
Client-side i18n makes it worse. If the language switcher swaps strings at runtime, then for a crawler every translation except the one baked into the HTML does not exist. You have one page pretending to be many.
Twelve lines, and they answer every edge case once. Default language at root. Trailing slash always — chosen because that is the form the hosting platform serves with a 200 and redirects to; a canonical pointing at a URL that 301s is a self-inflicted wound.
So the URL structure and the directory structure cannot diverge either. There is no mapping layer to get wrong.
One head builder serves every page type. It takes langs — the set of languages this exact page exists in — and derives everything from it:
Three properties fall out for free: Canonical and og:url are literally the same variable. They cannot disagree. hreflang is mutually referencing by construction. Every language of a page renders alternates from the same langs array, so page A links B and B links A automatically. The most common hreflang error — non-reciprocal annotation — is unrepresentable. A page that exists in only some languages emits alternates only for those. Because langs is computed per page: GUIDE_LANGS.filter((l) => GUIDES[l]?.[slug]).
The second constraint is harder, because it is a rule about content, and content is written by humans in a hurry. A localized long-tail page that ends up as a heading plus two sentences is worse than not shipping it: it drags the whole directory's quality signal down.
So the build measures every page it just rendered and refuses to make short ones indexable.
Two details matter here. It counts only, so boilerplate nav, footer and CTA cannot inflate a thin page into passing. And CJK has no spaces — splitting on whitespace would count an entire Japanese page as one gigantic "word" — so codepoints are converted to a rough English-word equivalent instead.
The gate itself is not a single pass, and this is the part I did not expect when I started:
Delisting a page removes it from other pages' related-links lists. That shortens those pages. Which could, in principle, push a borderline page under the threshold. So the gate iterates to a fixpoint rather than deciding in one pass.
Delisting is not deletion. The page is still written, still reachable, still linked — it just gets noindex,follow and drops out of the sitemap. Related-link rendering degrades the entry to plain text rather than removing it:
And the build reports what it did, so a regression is visible in the log rather than in Search Console eight weeks later:
The threshold is a proxy, not a measure of quality. Word count does not detect a padded page, a machine-translated page, or a page that says nothing at length. It only catches the specific failure of obviously too short. A page can clear 450 words and still deserve to be blocked; nothing here will tell you.
Rendering during the gate means pages are rendered more than once. runGate renders every page per pass, then the writer renders them again. For a site of this size that is milliseconds and completely irrelevant; if page generation ever became expensive, this loop would need memoizing per indexable set.
