DEV Community

Cover image for Four layers of defense for a multilingual site
Sergei Ivantsov
Sergei Ivantsov

Posted on AI-assisted

Four layers of defense for a multilingual site

I keep building multilingual sites. Restaurants, shops, now apartment listings on Northern Cyprus. People assume the hard part is translation. It isn't. The hard part is keeping four languages in sync after you ship, when nobody is looking.

Here is what actually holds a multilingual site together. Not one trick. Four layers, and each one catches a different kind of failure.

Layer 1: the generator

Text used to live copy-pasted across twenty HTML files: five page types, four languages. On September 7 that broke. The title, the og:title, and the JSON-LD name for one listing were out of sync on all four languages, because someone (me, with an AI agent) copied the string by hand one too many times. The Polish version used a different phrase entirely.

The fix wasn't "be more careful next time." It was build/generate-seo.js: one data file, one generator, twenty files written from it. A --check flag fails the build if a file drifted from the source instead of silently overwriting it. This is the layer that makes structural drift physically impossible, not just unlikely.

$ node build/generate-seo.js --check
SEO head blocks are up to date.
Body blocks are up to date.
Enter fullscreen mode Exit fullscreen mode

That's the whole contract: the generator either confirms the twenty files still match the source, or it fails the build. No middle state where a file is quietly out of sync.

Layer 2: the linter

The generator can't see style. It writes what the data file says, correctly, forever. If the data file itself is inconsistent, the generator faithfully ships that inconsistency four times.

So I wrote build/lint-text.js. It caught British spelling mixed with American in my own English copy. It caught the same empty hedge word duplicated across languages by the same translation habit: "actually" in English, "на самом деле" in Russian, "gerçekte" in Turkish, "naprawdę" in Polish — one language picks up a verbal tic, and if you don't check, it rides along into every translation.

Dash frequency needed per-language thresholds, not one global number. English prose runs light on em dashes. Russian carries more. Turkish and Polish sit somewhere in between. A linter tuned for one language would either flag good Russian prose as robotic or miss actual robotic English.

$ node build/lint-text.js
...
· listing reciaoglu    en  empty evaluative adjective   2
...
Notes: 0 warnings, 1 flag.
Enter fullscreen mode Exit fullscreen mode

(trimmed; the real run checks every listing in all four languages and prints a full table even when it finds nothing — that flag above was a real one, not staged for this article.)

Layer 3: the trained eye

Neither tool catches meaning. A flyer said "~50 km to both airports." True for Ercan. Off by 20 km for Larnaca. Grammatically fine, technically consistent, and wrong, because nobody checked the number against the actual reader driving to the actual airport. That's not a generator problem or a linter problem. That's a person who reads the sentence and asks "is this true for the person landing at Larnaca," and my background is translation, so noticing when a sentence says one thing in one language and something slightly different in another is close to the whole job.

-           <li>About 50 km to Ercan and Larnaca airports</li>
+           <li>About 50 minutes by car to Ercan airport</li>
+           <li>About 70 minutes to Larnaca — the route crosses the checkpoint</li>
Enter fullscreen mode Exit fullscreen mode

One commit, same change repeated across all four language blocks in the same file — because the flawed number wasn't a typo in one place, it was the same inherited assumption translated four times.

The same instinct works before anything is written, not just after. Nobody asked for a disclaimer page or an "about" page on a private listing site. I added both on day two: the site is not an agency, takes no prepayment, and the deal is between the two sides. No checklist told me to. It's the same trained eye, pointed at trust instead of at a sentence — noticing what a reader needs to see before they'll believe the rest of the page.

Layer 4: going back and looking

The three layers above run at build time. None of them catch a section that's simply missing from the template. data-reading, the "worth reading before you buy" block linking to the climate article, was absent from three of four homepage templates entirely. Not empty. Not there. The generator can't fill a slot that doesn't exist, and no linter checks for missing HTML structure.

I found it by doing the unglamorous thing: opening the live site periodically and reading it like a stranger would. Same pass caught the language button order drifting out of sync with the default language, and a footer note still describing two listings after two more had been added.

Why all four, and not just the best one

Each layer catches what the others structurally cannot. The generator kills copy-paste drift. The linter kills style drift the generator inherits from bad source data. The eye kills meaning-level errors no automated check understands. And periodic re-reading kills the class of bug where something simply isn't there to check.

Skip any one layer and a specific kind of mistake gets through, quietly, in all four languages at once.

This whole habit started with the same thought as the first post in this series: "let's just do it right." Turns out doing it right on a multilingual site means doing it right four times, once per language, every single time.

The site is still here and growing: girne-apartment-sale.com. The layer 1 generator is open source: seo-generator on GitHub.

Top comments (0)