

1/1/1970
Hoe ik i18n naar 20 talen heb uitgerold in 3 dagen
Hoi! Ik heb net een enorme klus afgerond waarbij ik Foony naar 20 verschillende talen heb vertaald. Het was een gigantische operatie waarbij ik bijna elk bestand in de codebase heb aangeraakt, maar het is me gelukt om alles in slechts 3 dagen rond te krijgen.
Hieronder leg ik uit hoe ik dat heb gedaan, de concrete cijfers achter de verandering, en waarom ik (alweer) mijn eigen vertaalbibliotheek heb gebouwd in plaats van de industriestandaard te gebruiken.
Waarom geen i18next?
Toen ik voor het eerst naar vertalingen keek, dacht ik natuurlijk aan de standaardoplossing: i18next en react-i18next.
In plaats daarvan besloot ik te optimaliseren voor onderhoudbaarheid door AI. i18next is krachtig, maar de variatie in de API kan ervoor zorgen dat LLMs gaan hallucineren of inconsistente code schrijven. Door de bibliotheek te beperken tot een simpele t() en interpolate(), zorgde ik ervoor dat 10+ parallelle agents 100% typeveilige code konden schrijven met bijna nul menselijke tussenkomst.
Ik was ook voorzichtig met het instappen in een groot ecosysteem dat later misschien breaking changes introduceert. Na eerder verbrand te zijn aan pijnlijke migraties zoals React Router v5 en MUI v4 → v5, weet ik dat het snel breken van backwards compatibility veel te vaak gebeurt in JavaScript-land. De kosten om later meervoudsvormen toe te voegen zijn lager dan de kosten om nu handmatig 139k regels code te migreren.
Ik wilde iets doodsimpels, superlichtgewicht en precies afgestemd op wat mijn team nodig heeft.
Dus heb ik het zelf geschreven.
Ik heb een beperkte subset van 3 KB gebouwd, speciaal ontworpen om nauwkeurige, grotendeels autonome AI-refactors mogelijk te maken. Daardoor kon ik me gedragen als één engineer die in 3 dagen het werk van een team van vijf personen over 3 weken afrondt.
De eigen implementatie
Ik heb een minimale i18n-bibliotheek bedacht die ge-gzip’t rond de 3 KB zit. Die stelt twee hoofdfuncties beschikbaar: getTranslation() voor niet-React-contexten en een useTranslation() hook voor componenten.
Die leveren t() op voor simpele stringvervanging en interpolate() voor als ik React-componenten in een vertaalstring moet injecteren (zoals een link of een icoon). Beide functies ondersteunen variabele vervanging, bijvoorbeeld "Hello {{thing}}", {thing: 'World'}.
Hier is de kern van de t() functie:
export function t(key: TranslationKeys, values?: Record<string, string | number>, locale?: SupportedLocale): string {
let namespace: string = '';
let translationKey: string = key;
// Check if key contains '/' - this indicates a namespace
const slashIndex = key.indexOf('/');
if (slashIndex !== -1) {
const parts = key.split('/');
namespace = parts.slice(0, -1).join('/');
translationKey = parts[parts.length - 1];
}
const targetLocale = locale ?? currentLocale;
const text = getTranslationValue(targetLocale, namespace, translationKey);
if (values) {
return interpolateString(text, values);
}
return text;
}
En de React hook:
export function useTranslation() {
const [language] = useLanguage();
return useMemo(() => ({
t: (key: TranslationKeys, values?: Record<string, string | number>) =>
t(key, values, language),
interpolate: (key: TranslationKeys, components: Record<string, ReactNode>) =>
interpolate(key, components, language),
}), [language, version]);
}
De kern van de hele bibliotheek is maar zo’n 580 regels code. Die doet onder andere:
- Lazy-loaden van vertaalbestanden zodat we niet alle 20 talen naar elke gebruiker sturen.
- Code-splitting van vertalingen per "namespace" (bijvoorbeeld
common,misc,games/{gameId}). - Een "debug"-locale die de ruwe keys laat zien, zodat ik kan checken of alles goed is aangesloten.
Om het systeem makkelijk onderhoudbaar te houden, heb ik ook uitgebreide documentatie toegevoegd in shared/src/i18n/README.md, waarin alles staat van de bestandenstructuur tot gebruiksvoorbeelden voor zowel client als server. Omdat ik geen standaardbibliotheek gebruik, is zo’n referentie superbelangrijk om nieuwe teamleden in te werken (of om mijn toekomstige zelf, of LLMs, eraan te herinneren hoe alles werkt).
In cijfers
Om je een idee te geven van de omvang van deze update, hier wat er in de codebase is veranderd:
- 20 talen ondersteund (plus een debug-locale voor dev).
- 360 locale-bestanden aangemaakt.
- 139,031 regels vertaalcode.
- 3,938 aanroepen van
t()toegevoegd aan de client. - 728 source-bestanden aangepast.
- 18 Engelse source-bestanden die als bron van waarheid dienen (16 games + common + misc).
Orkestreren met agents
Dit handmatig doen zou maanden aan geestdodend, mechanisch werk hebben gekost. In plaats daarvan heb ik meer dan een dozijn Cursor agents tegelijk ingezet om het zware werk te doen.
Ik begon met het opdelen van de codebase in "secties" op basis van mappen. Elke game op Foony kreeg een eigen map en een eigen vertaalnamespace. Zo blijft de initiële laadtijd klein, omdat je alleen de vertalingen laadt van de game die je speelt.
Ik draaide meerdere Cursor agents tegelijk. Ik gaf elke agent een specifieke sectie, bijvoorbeeld "converteer de Chess-game naar vertalingen", en die ging dan bestand voor bestand langs om user-facing strings te vinden en te vervangen door t('games/chess/some.key').
De agent voegde die key daarna toe aan het juiste Engelse locale-bestand, met een JSDoc-comment waarin staat "wat" en "waar" de string is. Die context is belangrijk bij het genereren van vertalingen naar andere talen, omdat het een LLM helpt begrijpen of "Save" betekent "Save Game Configuration" of "Save Your Draw & Guess Drawing".
Kwaliteitscontrole
Ik heb alle gegenereerde code snel nagekeken. De agents bleken verrassend goed, maar maakten af en toe fouten, zoals het plaatsen van de useTranslation hook na een vroege return-statement.
Sterk getypeerde vertalingen hielpen enorm. Zo wist ik zeker dat alle vertalingen voor elke locale alle juiste keys hadden (en geen verkeerde). Ook zorgde het ervoor dat aanroepen van t() en interpolate() alleen echte, bestaande vertaalstrings gebruikten.
Het type-systeem haalt alle mogelijke vertaalkeys uit de Engelse bronbestanden:
/**
* Extracts all possible paths from a nested object type, creating dot-notation keys.
* Example: {a: string, b: {c: string, d: {e: string}}} → 'a' | 'b.c' | 'b.d.e'
*/
type ExtractPaths<T, Prefix extends string = ''> = T extends string
? Prefix extends '' ? never : Prefix
: T extends object
? {
[K in keyof T]: K extends string | number
? T[K] extends string
? Prefix extends '' ? `${K}` : `${Prefix}.${K}`
: ExtractPaths<T[K], Prefix extends '' ? `${K}` : `${Prefix}.${K}`>
: never
}[keyof T]
: never;
export type TranslationKeys =
| ExtractPaths<typeof import('./locales/en/index').default>
| `misc/${ExtractPaths<typeof import('./locales/en/misc').default>}`
| `games/chess/${ExtractPaths<typeof import('./locales/en/games/chess').default>}`
| `games/pool/${ExtractPaths<typeof import('./locales/en/games/pool').default>}`
// ... and so on for all games
Dit geeft perfecte TypeScript-autocomplete, en elke typo in een vertaalkey wordt bij compile time al gevangen. De agents kunnen geen fouten maken als t('games/ches/name'), omdat TypeScript dat meteen afschiet.
Lokalisatie
Toen de Engelse omzetting klaar was, heb ik de overige locale-taken verder opgeknipt. Ik maakte elke agent verantwoordelijk voor het omzetten van één Engels locale-bestand naar een specifieke taal.
Ik gaf de agents bijvoorbeeld zo’n prompt:
Please ensure that ar/games/dinomight.ts has all the translations from en/games/dinomight.ts.
Use `export const account: DinomightTranslations = {`.
Iterate until there are no more type errors for your translation file (if you see errors for other files, ignore them--you are running in parallel with other agents that are responsible for those other files).
Your translations must be excellent and correct for the jsdoc context provided in en.
You must do this manually and without writing "helper" scripts, and with no shortcuts.
Ik overwoog om Cursor een script te laten maken dat elk van deze bestanden in een LLM voert en het resultaat laat genereren, maar ik wilde wat besparen op LLM-kosten. Een script gebruiken om alleen ontbrekende vertalingen bij te werken was de betere aanpak, en iets dergelijks ga ik waarschijnlijk in de toekomst weer doen. Ik zou graag bijhouden welke strings nog een update of vertaling nodig hebben, maar wil het simpel houden. Misschien verplaats ik het vertaalwerk ooit naar een database of zo.
Ik heb ook een "debug"-locale toegevoegd die alleen in development beschikbaar is. Daarmee kan ik alle vervangen strings zien om te checken of alles werkt (en ik vind het ook gewoon leuk). Als je de debug-locale gebruikt, geeft t() de key terug, verpakt in brackets:
if (targetLocale === 'debug') {
return `⟦${key}⟧`;
}
In plaats van "Welcome to Foony!" zie je dan ⟦welcome⟧, wat het supermakkelijk maakt om ontbrekende vertalingen te spotten.
Tot slot heeft een andere agent routing voor /{locale}/** geïmplementeerd, zodat dingen als /ja/games/chess naar de juiste taal routeren (in dit geval Japans).
De blog vertalen
De UI-strings vertalen is één ding, maar hoe zit het met de blogposts? Ik had geen zin om nóg meer agents op te zetten en te beheren om al mijn blogposts te vertalen.
Dat heb ik opgelost door een agent een script te laten maken (scripts/src/generateBlogTranslations.ts) dat het hele proces automatiseert.
Zo werkt het:
- Het script scant de map
client/src/posts/enop Engelse MDX-bestanden. - Het checkt welke vertalingen missen in de andere locale-mappen (bijvoorbeeld
posts/ja,posts/es). - Als een vertaling ontbreekt, leest het de Engelse content en stuurt die naar Gemini 3 Pro Preview met een specifieke prompt om de content te vertalen terwijl de Markdown-opmaak behouden blijft.
- Het slaat het nieuwe bestand op op de juiste plek.
Aan de frontend-kant gebruik ik import.meta.glob om al die MDX-bestanden dynamisch te importeren. Mijn PostPage component kijkt dan simpelweg naar de huidige locale van de gebruiker en lazy-loadt het juiste MDX-bestand. Als een vertaling ontbreekt (omdat ik het script nog niet heb gedraaid), valt de pagina netjes terug op Engels.
Conclusie
Op dat punt had ik een volledig werkende site, vertaald naar alle 20 locales!
Het waren drie knotsgekke dagen, maar het resultaat is een volledig gelokaliseerde site die voor gebruikers wereldwijd (meestal) native aanvoelt. Door een eigen, lichte bibliotheek te bouwen en AI-agents het saaie refactorwerk te laten doen, heb ik iets gedaan wat een jaar geleden nog onmogelijk leek: volledige i18n in 3 dagen voor een complexe website, door één engineer.
De toekomst van programmeren draait niet om zo snel mogelijk code tikken. Het draait om het orkestreren van AI-agents en het hebben van genoeg domeinkennis om hun output goed te kunnen beoordelen.