DEV Community

Mobeen Ahmed
Mobeen Ahmed

Posted on

i18n for 9 languages with a tiny JS dictionary and zero frameworks

My downloader's visitors come from everywhere. The analytics pie chart is a dozen flags, and a good share of them do not read English. I wanted the site in several languages, but the stack is plain PHP on shared hosting — no build step, no Node, no framework — so the usual i18n libraries were not an option. I built it with a small JavaScript dictionary instead, and it has been quietly working for months.

The whole system is one JS file, a dictionary of dictionaries:

const I18N = {
  en: { "download": "Download", "paste": "Paste your link", ... },
  es: { "download": "Descargar", "paste": "Pega tu enlace", ... },
  ...
};
Enter fullscreen mode Exit fullscreen mode

Nine languages, flat keys, one nested level. Every translatable element in the HTML carries a data-i18n attribute naming its key. On page load, a script reads the visitor's stored language preference (or falls back to the browser's navigator.language), then walks the DOM once, swapping textContent for each element whose key exists in that language's dictionary.

Three decisions made this work well on my constraints.

1. Flat keys, no nesting. Nested translation objects look elegant in documentation and become a maze in practice — you end up debugging a missing key through four levels of dot-path. Flat keys mean a missing translation is one console.warn and a visible fallback: if a key is absent in the chosen language, the code leaves the English text in place. The site never renders blank. English is the default language of the markup, so an incomplete translation is a degradation, not a breakage.

2. Translate at the leaf, not the sentence. Templates with interpolated placeholders ({count} videos) are where tiny i18n systems go to die, because word order differs across languages. I sidestepped it: dynamic numbers get their own element, and the surrounding static text is translated as whole short strings per language. It means more keys, but each one is a complete sentence a translator can actually translate. For a tool page with maybe sixty strings, the extra keys are cheap.

3. Persist the choice, respect the browser. The language selector writes to localStorage and the script reads it before touching the DOM — no flash of English first, because the swap happens on DOMContentLoaded, before first paint in practice on these light pages. If there is no stored choice, it maps navigator.language (e.g. es-MX → es) and falls back to English for anything unmapped.

The part people get wrong with DIY i18n is scope. I did not translate everything — legal pages and long-form guides stayed in English, and that is a deliberate trade-off, not laziness. The translated surface is the tool interface: the input placeholder, the buttons, the progress messages, the error states. Those are the strings a non-English speaker needs to use the tool; nobody needs the privacy policy in nine languages on day one.

There is one genuine gotcha I hit: SEO. JavaScript-swapped text is invisible to crawlers that do not execute JS well, and the lang attribute matters. Each page keeps lang="en" on the <html> tag and its canonical English content in the markup, so search engines always see a complete, coherent page. The translations are a progressive enhancement for humans, not a separate indexable surface. Trying to make nine JS-swapped languages rank in nine locales would need real per-language URLs, and that is a different project.

Maintenance is the boring part and it is fine. Adding a language is one dictionary object. Adding a string is one key in nine places — I keep a small checklist comment at the top of the file reminding me which keys are required so a new string does not ship translated in three languages and English in six (the fallback covers it anyway).

Would I do this for a large app? No — at scale you want a real i18n framework with pluralisation rules and translator tooling. But for a single-purpose tool site with a few dozen strings, the dictionary file is under 20KB, has zero dependencies, cannot break the build because there is no build, and took an afternoon to write. Sometimes the right tool is a lookup table.

This is how the language selector on ramrom.io works today — nine languages, one file, no framework, and the English fallback means it fails gracefully instead of failing visibly.

Top comments (0)