SecretoCho
menu

· 11 min

Five languages, five of every mistake

/en/new-developments/belgrade showed all of Serbia with no 404, “1 projects” read wrong in four of five languages, and every page carried all five.

Part 4 of 7 in the Building Novi Kvadrat series.

The five languages are for foreigners like me. That’s the whole reason Novi Kvadrat ships in English, Serbian, Turkish, German and Russian. Part 1 told what renting here was like for me. A platform meant to fix that can’t be a Serbian site with an English button on a few pages.

The research said the same thing in April. The Serbian portals were Serbian-only, and one competitor study put it as “English-first toggle, not ‘English added.’” In September a scan of 11 Serbian and regional homepages found: “No portal offers Turkish, Russian or German UI.”

So every language is a full site, with its own URLs and its own copy of every string. That made every page five pages, and every mistake came in fives. Most were silent: no error, no 404, just a page that was wrong and nothing to say so.

Five languages, one at a time

Novi Kvadrat didn’t start with five. The first commit, on 12 December 2025, had three locales: English, Serbian in Latin script and Serbian in Cyrillic, with Serbian as the default.

On 12 March 2026 the Cyrillic file, 699 lines, was deleted inside a commit titled “Fix admin panel issues: gallery saving, room fields, layout copy, city warnings, z-index”. The record doesn’t say why.

German and Turkish arrived on 17 April, 848 lines each, in “Add infinite scroll, bounds-based map loading, and i18n improvements”. Russian came the same day, in “Add 3D building map, Russian locale, and i18n improvements”. Four days later came “Internationalize all hardcoded text across the platform”.

An audit of the public site on 23 April compared the files by size: Russian at “~67% parity”, German “~47%”, Turkish “~45%”. It called Serbian and English “production-ready” and the others “best-effort”, and it advised: “Accept 5-10% missing key fallbacks (will default to EN)”. Twelve days later the gap was closed: “Translate tr/de/ru to full parity with sr (≈1465 keys per language)”.

On 30 June the default language moved from Serbian to English. It happened inside a commit titled “Buyer requests, supplier portal, saved-search matcher”, whose body calls it “i18n config tweak”. That’s all the record says.

30 Jun · English default

  1. English from 12 Dec 2025
  2. Serbian, Latin script from 12 Dec 2025
  3. Serbian, Cyrillic script 12 Dec 2025 – 12 Mar 2026
  4. German, Turkish and Russian from 17 Apr 2026, at full parity from 5 May

The diagram, step by step:

  1. 12 Dec 2025: the first commit has English and Serbian in Latin script, with Serbian as the default.
  2. It has Serbian in Cyrillic too, until 12 Mar 2026, when that file, 699 lines, is deleted. The record doesn’t say why.
  3. 17 Apr: German, Turkish and Russian arrive. An audit on 23 Apr puts them at about 45–67% parity, and on 5 May the gap is closed.
  4. 30 Jun: the default moves from Serbian to English. The commit’s body calls it an “i18n config tweak”.
every language is a full site, so each late one had to catch up to full parity.

After that, the files only grew. Counted as leaf strings, English held 3,237 on 6 May, 4,789 on 1 July, 7,831 on 1 August, 10,339 on 1 September, 14,183 on 1 October and 25,103 on 19 October. (The ≈1,465 in the 5 May title is what the commit added to each of the three files; its body says “Each locale now has 3246 keys, matching sr.”) Serbian has 25,483 today and Russian 25,695, because they carry extra plural forms. German and Turkish have 24,995 each.

6 May
3,237
1 Jul
4,789
1 Aug
7,831
1 Sep
10,339
1 Oct
14,183
19 Oct
25,103

The diagram, step by step:

  1. The files only grow. On 6 May the English file holds 3,237 leaf strings.
  2. 1 Jul: 4,789. 1 Aug: 7,831.
  3. 1 Sep: 10,339. 1 Oct: 14,183.
  4. 19 Oct: 25,103.
the files only grew, and every new string has to exist in all five.

A URL in every language

Each language has its own URLs. The new-builds page is /sr/novogradnja in Serbian, /en/new-developments in English, and /tr/yeni-projeler, /de/neubauprojekte and /ru/novostrojki in the other three. On disk there is one folder per page, named in Serbian Latin (the legal pages are the exception: their folders keep English names), and one table maps each folder’s route word to its other spellings:

// src/i18n/config.ts (simplified)
export const slugTranslations = {
  novogradnja: { en: 'new-developments', tr: 'yeni-projeler', de: 'neubauprojekte', ru: 'novostrojki' },
  projekat:    { en: 'project',          tr: 'proje',         de: 'projekt',        ru: 'proekt' },
  // …
}

Links go out through that table, and on the way in the middleware rewrites each translated word back to its folder name. A rewrite is not a redirect: the browser keeps /en/new-developments, and the server renders novogradnja. Today the general table is built from 15 route words, plus a few blog taxonomy words, all rewritten at any depth, and a second table of five words applies only to the segment right after the locale.

The first mistake was having two tables. The middleware kept its own hand-written copy, and on 25 July it turned out the copy had drifted, in a commit whose title begins “Fix 12 dead localized legal-page URLs”. Twelve language versions of the about, privacy, terms and cookies pages, among them /en/about, /de/datenschutz and /sr/kolacici, had been answering 404. The fix derives the middleware’s table from the source, “so they can’t drift again”, and a test locks that in.

Rewriting at every depth is a trap of its own. An ordinary word like account in the general table would be rewritten “anywhere in any public path”, so a blog post with that slug would silently become …/nalog and 404. And since the browser keeps the translated URL, usePathname() returns it, so a tab that compares it with the folder name never lights up. An audit “called this the reason not to localize at all.” We localized anyway, with the first-segment table and one shared table of account links. The account area’s inner words, like zahtevi and sacuvano, stay Serbian on purpose: they sit “behind auth and noindex, so a translated URL buys nothing, and every word added is another shadowing risk.”

Two more slipped through before they were fixed. On 28 July, a rewrite built from the bare path turned out to drop the query string, so /de/karte?city=beograd reached the page as a bare /de/mapa. And on 19 August, robots.txt turned out to disallow /admin/, /portal/ and /nalog/ with no locale in front, while every real URL starts with one: “The file looked correct while disallowing effectively nothing.”

In October an SEO audit found two URL forms per page, and that “internal links vote for the non-canonical one”: “181 hand-built Serbian-form hrefs in ~70 files.” I took the recommended side: “recommended is good”. (My prompts are quoted here with the spelling fixed.) Since 6 October, one module decides every public spelling, a ratchet test stops anyone from hand-building a Serbian-form public link again, and /en/projekat/<slug> answers with a 308 to /en/project/<slug>.

The language choice is remembered now, too. After two reports from the blog, where pressing Back after a language switch brought the old language back, I asked that a language change be remembered all the time. Since 10 September a cookie is written only when someone uses the switcher, and the middleware sends other-language pages to the chosen language with a 307. “No cookie ⇒ no redirect”, so crawlers and anyone who never switched get exactly the URL they asked for.

The city that wasn’t

The slug table also held city names: beograd is belgrade in English. The link helper walked that table over every segment of a path, while the middleware maps route words only. So a city went out translated, and nothing ever mapped it back.

// building a city link (simplified)
`${getLocalizedPath('novogradnja', locale)}/${citySlug}`   // ✅ /en/new-developments/beograd
getLocalizedPath(`novogradnja/${citySlug}`, locale)        // ❌ /en/new-developments/belgrade

The first sighting was on the sale pages, and a code comment from its July fix still explains it: “the missing getBaseSlug here is why /en/for-sale/belgrade SSR’d total=0”. The second, measured on a production build on 21 September, was quieter. /en/new-developments/belgrade titled itself “New Developments belgrade”, with the raw lowercase token. The projects API looked the city up, got zero rows, and then applied no city filter at all. In the words of the write-up: “The page is silently the whole of Serbia under a city chip.” And: “It does NOT 404, so nothing catches it.” Those links came from the footer’s SEO band, for every city in every non-Serbian language, and from the hero search.

  1. the link helpertranslates every segment: beograd → belgrade
  2. the middlewaremaps route words only, so belgrade stays
  3. belgradethe projects APIlooks the city up
  4. zero rowsno city filteran unknown city reads as none
  5. all of Serbia, no 404under a city chip

The diagram, step by step:

  1. The link helper walks the slug table over every segment of a path, so beograd goes out as belgrade.
  2. The middleware maps route words only. Nothing maps belgrade back.
  3. The projects API looks the city up and gets zero rows.
  4. Then it applies no city filter at all.
  5. “The page is silently the whole of Serbia under a city chip.” It doesn’t 404, so nothing catches it.
translate positions, never values, and make an unknown city a 404.

Two defaults stacked up: a helper that translated a value, and a filter that read an unknown value as none. The fix, on 6 October, is one URL builder whose header says: “Only POSITIONS are translated, never slugs.” The ratchet allows no hand-built Serbian-form public link, and a made-up city now gets a 404 instead of a confident page about everything.

What a key test can’t see

The cheapest five-language gate is a key test, and the repo leans on it: 165 test files check locale keys, and a standing rule asks that every new key be a non-empty string in all five locales. A key test proves a string exists. It can’t tell you the string reads right.

Plurals. A single template like {count} projects prints “1 projects”, and its Serbian and Russian siblings read “1 projekata” and “1 проектов”: wrong for a count of one in four of the five bundles. The fix is a plural node per counted string, with the form chosen by Intl.PluralRules, “never with count === 1”. Serbian alone is worth checking at 1, 2–4, 5 and up, 21, 22–24 and 11–14. A related bug printed “1234 agencies” next to “1,234”, because the plural helper filled in the raw number. It was “Invisible below 1 000, which is why fixture-sized tests pass.”

Labels. Moving from Radix to Base UI “silently turned EVERY closed Select in the app into its raw value … across 227 call sites”: long_term, or a bare uuid, where a label in the reader’s language belonged. It was fixed centrally on 12 August. The same day it turned out that “11 portal error codes had no locale keys at all”, so a duplicate slug showed the literal key portal.project.errors.slugTaken.

Translations. On 19 August, feedback from an agency led to a fix in the AI description generator. It had treated English as the source: its prompt said to write “the SAME description in five languages”, so “the Serbian this market reads arrived as translationese.” Worse, a check that rejected any draft missing a number of 10 or more from the listing data would have rejected every faithful translation of an estate agent’s own text, with figures like 2.900 €/m², and its fallback would then have written a template over the agent’s Serbian. The doc’s line: “The better the agent’s copy, the more likely the destruction.” Now, when Serbian is the source, the response can’t carry Serbian at all.

The locale files themselves were fragile too. A plain JSON parse and re-save reflows the hand-compacted objects, and at the wrong indent rewrites all of a file’s roughly 23,000 lines, so keys go in as text edits. On 18 August, one session ran git checkout -- on the locale folder to undo its own bad re-save, and a parallel session lost 21 keys in each of the five files, which it retyped from its own context. On 23 September, five agents inserted 128 keys into all five bundles at once through one helper, with “zero reflow and zero lost writes”.

Every page carried every language

The translation hook, use-translations.ts, statically imported all five locale files. So every page’s first load carried about 1,301 kB of gzipped locale JSON, “every language for every visitor.” The project notes put the cost plainly: “every key any phase added cost every page five times.” It surfaced in the 3D buildings work, when that phase’s own locale text added 24.1 kB gzipped to the project page.

The fix, on 5 October, gave each language its own chunk. The first attempt didn’t hold: “Five components chosen by a layout did NOT work: Turbopack merged them into one chunk listed in the layout’s entry files”, which was proved on a minimal app the same day. React’s newer use() caused a hook-order warning in next dev, so the hook suspends the older way, by throwing a promise while its language loads.

The English home page, /en, went from 2,025 to 963 kB gzipped on first load. A project page in English went from 2,272 to 1,211, and the English sale page, /en/for-sale, from 2,080 to 1,016.

/en, before
2,025
/en, after
963
a project page, before
2,272
a project page, after
1,211
/en/for-sale, before
2,080
/en/for-sale, after
1,016

The diagram, step by step:

  1. Before: every page’s first load carries about 1,301 kB of gzipped locale JSON, “every language for every visitor.”
  2. 5 Oct: each language gets its own chunk. The English home page, /en, drops from 2,025 to 963 kB.
  3. A project page in English: 2,272 → 1,211.
  4. The English sale page, /en/for-sale: 2,080 → 1,016.
each new key cost every page five times, until each language got its own chunk.

Numbers that mean the same everywhere

Some mistakes weren’t in the words at all. On 19 August a number field turned out to parse the same keystrokes differently per language: “in sr/de/tr the dot is the GROUP separator, so ‘0.5’ parses as ‘05’ → 5; in en the comma does the same to ‘0,5’. The field displayed half a room and stored five.”

Money has its own local language. On 10 September I made the euro the listing currency, because “Serbian property is priced and negotiated in euros”. Rentals had been dinar-native until then, but “Existing rows keep the figure the landlord typed”, because converting them “would have replaced exact figures with rounded estimates.” A dinar figure derived from euros, at a fixed rate of 117, carries a ~. A euro figure never does: “a euro figure is never approximate — even on a listing the landlord priced in dinars.”

The statistics had to speak the official language too. On 5 July I ruled that the platform’s own figures were wrong and must not appear, so the statistics pages show official data only. On 31 August a newer official source, RGZ, showed what stale data had cost. “The map was under-reporting Belgrade by about a fifth.”

Even “novogradnja”, the word in our own URL, turned out to need a definition. In the older RGZ quarterlies it “meant any first sale rather than a dwelling built 2021 or later.” Charting those quarters next to the new ones “would silently splice two definitions together”, so the new series starts in the first quarter of 2026, and when it shipped it held two points.

What I’d pass on

Translate positions, never values. A route word has five spellings and one meaning. A city or a slug is data, and a helper that translates data needs something to translate it back on the way in. Ours had nothing, so one module now decides every public spelling, and a test keeps everyone going through it.

Keep one table, and derive every copy from it. The 12 dead legal URLs came from a hand-kept copy of the slug table. Now the copy is computed from the source, and a test pins the two together.

Make an unknown value fail loudly. Treating an unknown city as no filter turned a translation slip into a confident page about all of Serbia. A 404 is a bug someone sees; a page about everything isn’t. Many of the mistakes in this part had the same shape: a quiet fallback that looked like an answer.

Let each language count and parse for itself. Plurals and decimal separators follow the locale’s rules, not English ones. Intl.PluralRules knows Serbian and Russian plurals; a fixture with five items tests one form and misses the rest. Check what a counted string reads at 1, at 2 and at 1,234.

Price every string in bytes. Each new key cost every page five times, and it took one phase’s measurement to see it. Measure what a language costs a real page, in kilobytes, early.

Five languages are the part of Novi Kvadrat that people read. The next part is about what they look at: floor plans traced by hand instead of recognized by a model, and buildings raised on a 3D map. That’s Part 5.

The series

  1. Three flats and a platform
  2. Research before anything
  3. See it before you build it
  4. Five languages, five of every mistake
  5. Drawn, not recognized
  6. Silently
  7. More hours than I should
cd ..