Setting Up Internationalization in a Modern JavaScript Project
Most people hit a wall when they try to add translations to an existing codebase. They start with "Hey, maybe I'll just swap some strings," and three weeks later they're fighting with date formatting, pluralization rules, and a JSON file that looks like it was written by someone who doesn't speak their own language. I've been through that three times now. Here's what actually works. The core idea behind proper globalization (often called i18n, from the counting of letters between the first and last character) is that your application should never know what language the user sees. Everything flows through a single abstraction layer: a key, not a string. You write {t('users.welcome')} everywhere, and at runtime the system resolves that key to the appropriate translation based on the user's locale setting. I ran into a real problem once where I was using a globalization library for a European-facing SaaS product. The setup looked fine — I had English, German, and French. Then a client in Switzerland reported that dates were rendering as MM/DD/YYYY even though their browser was set to Swiss German. The issue was that the library was falling back to the default locale instead of respecting the de-CH regional variant. What fixed it was explicitly mapping that locale to the German translation bundle and adding a custom number formatting override. Most tutorials skip over regional variants entirely.
The actual workflow breaks down into a few steps that are straightforward but easy to mess up in sequence. Step one: extract every user-facing string into keyed references. This means going through your components and replacing literal strings with translation calls. A string like "We couldn't find any results" becomes {t('search.no_results')}. There's no shortcut here — you have to do it manually or run a script. I wrote a simple regex-based extraction script that scans for quoted strings longer than four characters inside component files and outputs them to a skeleton en.json file. Took about twenty minutes and saved me from missing half the strings in the UI. Step two: set up your locale directory structure. Create a locales/ folder with subdirectories for each language. Each language gets its own JSON file. Keep the structure flat at the top level and nested only for namespacing — something like locales/en/common.json, locales/en/users.json. Do not over-nest. Every extra level of nesting makes it harder for non-technical translators to work with the files.
Step three: configure the runtime resolver. This is where most projects go wrong. You need the library to detect the locale automatically (checking browser settings, URL parameters, stored preferences in order of priority) and then load only the translation bundle for that locale. Lazy-loading bundles matters if your app has more than a few languages. Loading all bundles upfront adds unnecessary JavaScript to every page. Here's what a minimal but functional setup looks like:
Get the Full Details
import { createI18n } from 'your-i18n-library';
const i18n = createI18n({
fallbackLocale: 'en',
supportedLocales: ['en', 'de', 'fr', 'es'],
messages: {
en: () => import('./locales/en/common.json'),
de: () => import('./locales/de/common.json'),
fr: () => import('./locales/fr/common.json'),
es: () => import('./locales/es/common.json'),
},
});
export default i18n;
The arrow function for each locale is what enables lazy loading. If you pass a static object reference, the bundler will inline everything and defeat the purpose. Step four: handle plurals and gender correctly. This is the part nobody talks about until their Korean or Russian translation looks broken. English plurals are trivial — one item vs N items. Polish has six plural forms. Arabic has five. If your globalization tool doesn't support CLDR plural rules natively, you will write wrong translations for half the world's population. Check what your library supports before committing to it. I learned this after shipping a notification system that said "3 friends" in Polish when it should have said something completely different. Took two days to fix after launch. Step five: add a locale switcher that persists. Don't rely on browser detection alone. Users change devices. Store the preference in localStorage or a cookie. The switcher should update the locale immediately without a full page reload if possible.
Common Pitfalls That Will Cost You Time
String concatenation in translations is a trap. Never build sentences by interpolating variables into raw strings like "Hello " + name. Some languages put the verb at the end. Use template strings with named placeholders instead: {t('greetings.hello', { name })} with the template being Hello {{name}}. This gives translators the flexibility to reorder parts without breaking grammar. Hardcoded dimensions and spacing based on text length will break in German. German words are longer. Period. Your CSS needs to handle overflow gracefully, or you need to design around expandable containers from the start. I once shipped a modal in German that was 40 pixels too narrow because the button text expanded past the container boundary. The workaround was switching to flexbox with min-width constraints instead of fixed widths for all locale-dependent UI elements. Another thing that catches people: right-to-left language support. If your product serves Arabic or Hebrew markets, you need dir="rtl" on your HTML root and a complete CSS review. Most CSS frameworks have RTL modes now, but if you've written custom positioning for icons or directional cues, those need manual adjustment. Half of RTL issues come from things like margin-left instead of margin-inline-start — the logical property version handles both directions automatically.
When Globalization Isn't Worth It
Let's be honest about the cost. Proper i18n adds roughly 15-30% to your initial development time and requires ongoing maintenance as new features ship. If your product is internal tooling for a single-country company, skip it. If you're targeting a single linguistic market, localizing for that one language is different from building a globalization infrastructure — you can often get away with a simpler string-swapping approach without the full framework. The key question is whether you'll need more than one language within the next year. If the answer is no, don't over-engineer it now and regret it later when you're retrofitting. For very small teams, I've seen success with a lightweight approach: a single dictionary object per language, loaded at startup, with no lazy loading. It works fine for apps under five thousand strings. The performance argument for lazy loading only becomes real past that threshold.
![[Why Globalization Works (Yale Nota Bene)] [Author: Wolf, Martin] [February, 2005]: Martin Wolf ...](https://m.media-amazon.com/images/I/41kz8sNkqAL.jpg)
Wolf Why Globalization Works
The reason globalization efforts succeed when they succeed is simplicity of the abstraction. Once your strings are keyed and your locale resolution is automatic, adding a new language is a matter of writing three or four JSON files and nothing else. That's the whole point — the infrastructure does the hard work so the actual localization becomes a content problem, not an engineering problem. Most projects that fail at this don't fail because globalization is hard. They fail because they started too late and tried to bolt it on after the UI was already built with hardcoded strings woven into templates, component logic, and error messages scattered across the codebase. Do it cleanly from the beginning and it stays manageable. Push it off and it becomes a rewrite. If you want to look at a working example, there are starter repositories on GitHub that implement this pattern with React, Vue, and plain JavaScript. Search for the library name plus "i18n example" and you'll find ones with proper plural handling and lazy loading already configured.