Every sub-application kept its own locale files, and every translation key followed a nested path like t('common.actions.confirm'). Both were tedious on their own. Together, they made automatic translation impossible later on - so fixing them became the first step toward a pipeline that could translate itself.
Problem 1: Locale Files Scattered Across Every App
Each sub-application had its own locale directory:
/apps/crm/locales/en.json
/apps/crm/locales/es.json
/apps/crm/locales/ca.json
/apps/hrms/locales/en.json
/apps/hrms/locales/es.json
/apps/hrms/locales/ca.json
// ... repeated across every appThe intent was isolation - each app owns its own translations. In practice, this meant the same strings were duplicated across apps, and the app was loading and merging dozens of locale files on startup just to render text.
The fix was a deep merge of every app's locale files into a single file per language:
/src/locales/en.json
/src/locales/es.json
/src/locales/ca.jsonThis step alone fixed the duplication and startup cost - but it also turned out to be a prerequisite for automating translation later. A translation script needs one source of truth to scan and diff against, not dozens of scattered files per app.
Result: locale files dropped by 1.62MB - 77% smaller.
Problem 2: Unreadable, Duplicated Keys
The deeper problem was how we were using i18next. Keys followed nested path conventions:
t('common.actions.confirm');
t('crm.customers.table.headers.name');
t('forms.validation.required');This looks organized on paper. In practice, it created three compounding problems:
- Nobody could remember the paths. Every new usage meant either searching the JSON files or guessing at a path and being wrong.
- Duplicates were everywhere.
common.actions.confirmandcrm.actions.confirmmight point at the same English string "Confirm" - nobody could tell without opening both JSON files. - The code told you nothing. Reading
t('common.actions.confirm')in a component gives zero indication of what the user actually sees on screen.
There was also a harder blocker: a translation API can make sense of "Confirm". It can't make sense of "common.actions.confirm". As long as keys were paths instead of text, automatic translation was off the table entirely.
The Fix: Use the Display Text as the Key
The insight was that the key didn't need to be a path at all. It could just be the English text itself:
// Before
t('common.actions.confirm');
// After
t('Confirm');This is instantly readable - the code now tells you exactly what renders. Duplicates become structurally impossible - if two places need to show "Confirm," they use the same key by definition. And critically, it unlocked automatic translation, because the key is now self-describing English text rather than an opaque path that a translation API can't interpret on its own.
The idea was simple. Applying it to 10,000+ existing usages across the codebase was not.
From Path to Sentence
The actual migration was a lookup problem: for every nested key in the codebase, find what English string it pointed to in en.json, and swap the key for that string.
// Before (en.json)
{
"common": {
"actions": {
"confirm": "Confirm"
}
}
}
// After (en.json)
{
"Confirm": "Confirm"
}For English, the key and value end up identical - which looks redundant until you see the Spanish file, where the key stays the English string and the value becomes the translation:
// es.json
{
"Confirm": "Confirmar"
}en.json was the single source of truth for this - the migration read English values only, since the new keys are built on the English text, not the translated text. Spanish and Catalan were regenerated afterward, fed straight into the automatic translation pipeline that the new key structure had just made possible.
Touching 10,000+ call sites in a production codebase is nerve-wracking - one bad replacement and a user sees a blank string instead of a button label. What made it safe was that the transformation was entirely mechanical: same lookup, same replacement, every time. A script doesn't get tired on file 8,000 the way a person does.
Results
- 1.62MB removed from locale file consolidation - 77% smaller locale files
- ~800KB removed from the key refactor - shorter keys repeated across thousands of call sites add up fast
- 10,000+
t()call sites migrated to flat, self-describing keys - Zero duplicate keys possible going forward, by construction
- Unlocked automatic translation for all future strings
What started as cleanup ended up paying for itself twice - once in bundle size, and once in unlocking a translation pipeline that wouldn't have worked with the old structure.
This post is part of a series on scaling a frontend platform to 18 apps. Read the full overview.