We recently launched a small web tool in 17 languages. The site has about 1,500 pages. There is no framework, no CMS and no build service, only one Python script that writes HTML files and Cloudflare Pages to serve them.
This post covers the parts that turned out to matter. They are mostly boring details that break silently when you get them wrong.
1. One JSON file per page, one folder per language
The content is laid out like this:
content/
en/
_ui.json # nav labels, buttons, footer text
home.json
about.json
story-viewer.json
fr/
_ui.json
home.json
...
Each page file has a slug, a title, a description, a keyword and a list of typed blocks (p, h2, list, faq, figure). The builder never guesses a language. A language exists only if its folder has a _ui.json:
def langs():
only = os.environ.get("PD_LANGS")
have = [p.name for p in CONTENT.iterdir() if p.is_dir() and (p / "_ui.json").exists()]
if only:
return [l for l in only.split(",") if l in have]
return sorted(have, key=lambda l: (l != "en", l))
The PD_LANGS override exists because of a mistake we made. Translations were running in the background, and we deployed while half of a language was written. For about two minutes /vi/ had pages but no homepage. Now the rule is simple: while translation is in progress, build with an explicit list of the finished languages.
PD_LANGS=en,ar,fr,es,de python build.py
English lives at the root and every other language gets a prefix:
def page_url(lang, slug):
prefix = "" if lang == "en" else f"/{lang}"
return f"{prefix}/{slug + '/' if slug else ''}" or "/"
2. hreflang: reciprocal, self-referencing, with x-default
Search engines ignore hreflang tags that don't point back at each other. Every page therefore lists all of its translations, itself included, plus an x-default that points to English:
link_alt = "".join(
f'<link rel="alternate" hreflang="{l}" href="{SITE}{u}">'
for l, u in sorted(alts.items()))
link_alt += f'<link rel="alternate" hreflang="x-default" href="{SITE}{alts.get("en", url)}">'
alts is built once per page id across all languages. When a page is missing in one language, it is missing from every page's list, so the sets stay consistent.
3. Sitemap index, split into chunks
A single sitemap can hold 50,000 URLs, but smaller files are easier to debug in Search Console because you can see which chunk has problems. We split at 1,000 URLs and write a sitemap index:
parts = [sm[i:i + SITEMAP_CHUNK] for i in range(0, len(sm), SITEMAP_CHUNK)] or [[]]
for n, part in enumerate(parts, 1):
(DIST / f"sitemap_{n}.xml").write_text(...)
(DIST / "sitemap.xml").write_text(
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
"".join(f"<sitemap><loc>{SITE}/sitemap_{n}.xml</loc></sitemap>"
for n in range(1, len(parts) + 1)) + "</sitemapindex>")
Each <url> entry also repeats the hreflang alternates as xhtml:link. It's redundant with the HTML tags, but it costs nothing.
4. Measuring text width in SVG without a browser
Every page has a few generated SVG figures, such as a phone mockup or a comparison table. SVG has no text wrapping, and the same label might be in English, Japanese, Hindi or Arabic. We needed a rough width estimate in Python:
def text_w(s, size, weight=600):
"""rough rendered width: CJK/fullwidth = 1em, Indic/Thai 0.75em, others ~0.55-0.72em"""
w = 0.0
for ch in str(s):
o = ord(ch)
if 0x2E80 <= o <= 0xA4CF or 0xAC00 <= o <= 0xD7A3 or 0xFF00 <= o <= 0xFF60:
w += 1.0
elif 0x0900 <= o <= 0x0E7F:
w += 0.75
elif ch in " .,:;!|il'1":
w += 0.3
elif ch.isupper() or ch in "mwMW@%":
w += 0.72
else:
w += 0.6 if weight >= 700 else 0.55
return w * size
It isn't exact. It doesn't need to be: the job is to decide when to shrink the font or wrap to a second line. Before we had it, Japanese labels overflowed their boxes and Thai labels were cut in half.
5. An audit script that blocks the deploy
This was the most useful piece. audit.py reads the built folder, or the live site, and exits with code 1 on any failure. For every URL in the sitemap it checks:
- a self-referencing https canonical, and no stray
noindex - exactly one
<h1>, with no skipped heading levels - a title and description that exist, aren't duplicated, and fit their length limits
- the page's keyword appears in the title, the H1 and the first paragraph
- JSON-LD that parses, has no dangling
@id, and includes the types each page kind needs (Organization, WebSite, BreadcrumbList, FAQPage, and so on) - reciprocal hreflang with x-default
- every image has
alt,widthandheight - every internal link resolves to a built file
-
robots.txtandllms.txtare well-formed
The first full run on 1,581 pages reported about a hundred failures. They were mostly translated descriptions that were too long and H2s that had lost the keyword in translation. You would not catch those by clicking around, especially in languages you can't read.
6. Cloudflare Pages + Functions
The static output goes to Cloudflare Pages with one command:
npx wrangler pages deploy dist --project-name mysite --branch main
The few dynamic endpoints live in functions/api/*.js as Pages Functions, on the same domain with no CORS. robots.txt disallows /api/ so crawlers don't waste their budget on it.
7. Telling search engines
After every deploy, one small script reads the sitemaps and sends all URLs to IndexNow (Bing and Yandex), in batches of up to 10,000 URLs per request:
urls = [u for f in sorted(glob.glob(f"{d}/sitemap_[0-9]*.xml"))
for u in re.findall(r"<loc>(.*?)</loc>", Path(f).read_text(encoding="utf-8"))]
body = json.dumps({"host": HOST, "key": KEY,
"keyLocation": f"https://{HOST}/{KEY}.txt", "urlList": urls})
Google doesn't use IndexNow, so for Google we submit the sitemap index in Search Console and wait.
What we would do again
- Content as JSON, layout in code. Translators and scripts touch only the JSON. The HTML can't drift between languages.
- An audit gate before every deploy. It costs a few seconds and catches the errors you can't see.
- An explicit language list while translation is running. Never deploy "whatever is in the folder".
The site is SnapViewer, a free viewer for public Snapchat stories and profiles. The build approach doesn't depend on what the site does. A Python script, a folder of JSON and a static host take you a long way.
This article was written with AI assistance and checked against the real build code.
Top comments (0)