When you tell people you have your own build-time localization, the first question is always “why not i18next?” Fair question. However, it actually contains two completely separate decisions bundled together, and until you pull them apart you can’t answer either one honestly.
We run a large SPA on webpack, React and TypeScript with more than 10 languages. The locale list is known before deployment and doesn’t change between deploys — we know it at build time as precisely as we know the Node version. That sounds like a mundane detail, but it’s the fact that determines the entire localization architecture. It’s what led us to write a few-hundred-line webpack plugin instead of reaching for a library.
This post covers the reasoning: What the different i18n approaches actually are, why a fixed locale list tips the balance toward build-time translation, what existing baked solutions look like and why none of them fit and how our plugin works — compile once, produce N locale-specific bundles from the output.
The Fundamental Split in i18n Approaches
There’s one question that divides all frontend localization approaches: Does translation resolution happen at render time, or is it baked into the bundle beforehand?
On the runtime side: The dictionary lives in a separate file, gets fetched independently and the right key is looked up at render time. On the baked side: Translations are already embedded alongside the code, nothing to resolve at runtime.
Everything else is a variation within one of these two camps.
Runtime: One Bundle, Translations Loaded Separately
This is the standard approach — i18next, Lingui, react-intl. One bundle for all locales. On load, the app detects the user’s language and fetches the right translations file. In code you call t(‘key’), which looks up the string in the loaded dictionary at render time.
Pros
- One bundle — CDN cache is shared across all users regardless of locale.
- Language switching without a page reload.
- Dynamic key names work fine.
- Translators can work in a pipeline independent of developers.
Cons
- Two requests instead of one: JS bundle, then translations file.
- Runtime library ships in the bundle (~34 KB for i18next minified).
- Translations live outside hashed JS files — translation cache invalidation is a separate concern, and ‘new code, old dictionary’ desync becomes its own class of production bug.
- The entire dictionary ships to the browser even if a given page uses 10% of the keys.
When to use This: You need language switching without a page reload; you need dynamic key names.
Baked: Translations Embedded at Build Time
The other camp — Paraglide.js, webpack-localize-assets-plugin, our plugin. The app compiles once, and translations are baked into already-minified output. The result is a separate bundle per locale. No runtime resolver, no separate translations file.
Pros
- One request — the browser gets everything at once.
- No runtime library in the bundle.
- Per-key tree-shaking — each chunk contains only the strings it actually uses; no shared dictionary blob.
- Cache invalidation is automatic — translations live inside hashed JS files, update a string, the hash changes.
- TypeScript catches key typos at compile time, before the build.
Cons
- Translation updates require a rebuild and redeploy.
- Language switching requires a page reload (different bundle).
- Keys must be static string literals — t(`meta.${page}Title`) won’t work.
- N locales = N bundles on CDN (but with compile-once this is cheap).
When to use This: Bundle size and initial load performance matter.
Two Sub-Strategies Inside Baked
Once you’re in the baked camp, there’s a second fork: How do you produce N locale-specific bundles?
Separate Build per Locale: One entry point (or full bundler run) per language. No runtime resolution, translations are in the code. Clean but expensive in build time. You pay webpack’s full price for each variant: Module graph, loaders, chunk optimization, manifests. The most expensive part is usually minification: Terser chews through the output code, and that’s typically the heaviest phase of a production build. Ten Terser runs over an app that differs between variants only in text strings means paying for repeated heavy work, not new functionality. This is how Angular worked before v9.
Compile Once + Post-Compilation Substitution: The app compiles once, leaving recognizable markers in the output instead of translations. A separate step reads the compiled assets and bakes in strings for each locale, producing localized copies. No runtime resolution, no repeated compilation — you pay for the heavy work once and then multiply cheaply. This is what @angular/localize does (Angular rewrote to this approach in v9 explicitly to remove the per-locale full-build requirement). This is our approach.
Why Compile-Once Matters for Build Time
The cost curves diverge sharply as locale count grows. Separate builds: Linear with locale count. Compile-once + inline: nearly flat — you pay for compilation and minification once, and the localization step is string substitution plus hash recalculation on already-minified output, which is cheap in proportion to bundle size.
At 10 locales, this is the difference between ‘20 minutes × 10 = 200 minutes of CI’ and ‘20 minutes compilation + ~2 minutes inlining for all locales’. Terser runs once, not per language.
Angular landed here through their own analysis. Before v9, they compiled the application fresh for each locale. @angular/localize rewrote this to “compile once, then translate for each locale” — post-compilation inline into already-built output. The Angular docs describe this explicitly as eliminating the per-locale full-build requirement. The details differ (Angular produces a full-output copy per locale; we substitute at the individual chunk level), but the principle is the same. We arrived at the same conclusion independently on a different stack.
How the Plugin Works
In source code, we write normal i18n() calls inside React components:
const SettingsButton: React.FC = () => {
return <button>{i18n(‘menu’, ‘settings’)}</button>;
};
const FileCounter: React.FC<{count: number}> = ({count}) => {
return <span>{i18n(‘files’, ‘count’, {count})}</span>;
};
The first argument is the keyset (namespace), the second is the key within it. The helper is strictly typed: The first argument is constrained to real keyset names, the second to keys within the selected keyset. TypeScript catches typos in both before build time, with autocomplete on both arguments.
Step 1: A Marker That Survives Minification
To find all translation calls in minified output, you need a marker that survives compilation and can’t be confused with anything else. We didn’t add a Babel plugin with AST parsing — extra pass, its own loader integration, its own edge cases and AST parsing are measurably slower. Instead, string-replace-loader transforms i18n( → SECRET_I18N( at module load time. Cheap and fast.
{ search: ‘i18n\\(‘, replace: ‘SECRET_I18N(‘, flags: ‘g’ }
SECRET_I18N is deliberately eye-catching, survives minification and is trivially caught by regex in built files. Yes, string replacement is less precise than AST transformation — in theory it could match i18n( inside a comment. In practice with this signature, it never fired, and the cost difference is not comparable. This is a conscious trade of strictness for simplicity, not a claim that regex beats AST.
Hard Prerequisite: Key names must be static string literals. The first two arguments — keyset and key — must be literals. Parameters (third argument and beyond) can be any dynamic value; we don’t match or touch those. A key name built from a variable won’t be matched by the regex. This required a migration plus a lint rule to prevent dynamic keys from coming back.
Step 2: One Compilation, Filenames With Holes
webpack compiles the app once. The output filenames include a placeholder webpack doesn’t recognize and therefore leaves literally:
output: {
filename: ‘[chunkhash].[lang].js’, // webpack substitutes [chunkhash], leaves [lang] as-is
}
After the build, the disk holds template files: a3f9c1.[lang].js. The heavy work is done — graph built, code minified — but which language? Decided later, when forking variants are cheap.
Step 3: Inline at the processAssets Stage
The plugin hooks into webpack’s processAssets at the DERIVED stage — source assets are fully formed but nothing has been written to disk yet. For each locale, we walk the JS assets and replace markers:
const I18N_MARKER = /SECRET_I18N\(\s\*[‘”]\(.+?)[‘”]\s\*,\s\*[‘”]\(.+?)[‘”]\s\*(,|\))/g;
function inlineI18n(source, keysets) {
return source.replace(I18N_MARKER, (\_full, keyset, key, lastSymbol) => {
const value = keysets[keyset]?.[key];
if (value === undefined) {
throw new Error(\`Can’t find key ${keyset}:${key}\`); // fail at build, not silently at runtime
}
const isFn = typeof value === ‘function’;
const code = isFn ? value.toString() : JSON.stringify(value);
if (lastSymbol === ‘,’) {
return isFn ? \`(${code})(\` : \`(function(){return ${code};})(\`;
}
return isFn ? \`(${code})({})\` : code;
});
}
Note the function branch. A translation isn’t always a static string. Pluralization — ‘1 file’ versus ‘2 files’ — needs logic: In languages with complex plural rules, there can be up to six forms per CLDR, not expressible as a simple conditional. Which form to use depends on a number that arrives as a parameter — the third argument in i18n(‘files’, ‘count’, {count}). Therefore, we store such translations as (params) => string functions and inline them via value.toString() directly into the bundle. The call site becomes an immediately-invoked expression. This is conceptually the same thing @messageformat/core does when it compiles ICU messages into real JS functions — except we bake them into the call site.
There’s an honest cost: toString() serializes only the function body, not its closure. The function must be self-contained, and it’s duplicated at every call site. After post-processing, no helper call remains in the output — just the inlined value or function. Zero runtime resolution.
The Tricky Part: It’s not the Strings, it’s the Hashes
Once you write translated text into a JS file, its content changes. The content hash in the filename must change too — otherwise the browser and CDN are within their rights to serve the cached old version: The filename didn’t change. If you hash on code content alone rather than code-plus-translations, you get exactly this bug: Update a translation without touching code, filename unchanged, cache serves stale content and a locale silently ‘freezes’ in production.
The fix is rehashing from final content:
const newContent = inlineI18n(source, keysets);
const contenthash = util.createHash(hashFunction).update(newContent).digest(‘hex’).slice(0, len);
compilation.emitAsset(asset.name.replace(asset.info.chunkhash, contenthash), new RawSource(newContent));
This triggers a cascade. One chunk is renamed — but the entry file references it by its old name. So, you have to update references in the entry file (only JS chunk references — CSS content didn’t change, its hash is unchanged, renaming it would be wrong and would invalidate cache unnecessarily). The entry file’s content changed, so it gets a new hash and a new name. All changed names must propagate to the server manifest that SSR uses to find assets. The full cascade from a single string replacement:
for each language:
substitute markers with translations
recalculate content-hash, rename chunk
update JS chunk references in entry file (don’t touch CSS)
rename entry file (its content changed too)
update paths in server manifest
webpack has a built-in RealContentHashPlugin for similar tasks, but it didn’t fit here: We’re creating ‘new’ assets post-factum with our own specifics. So, we handle the cascade ourselves. This is the only part of the baked approach where it’s easy to get burned: Miss one link in the chain and some locale silently falls behind in production, and the cache did exactly what you told it to do.
Here’s the plugin skeleton with the full cascade:
const path = require(‘path’);
const {sources, util} = require(‘webpack’);
const MARKER = /SECRET_I18N\(\s\*[‘”]\(.+?)[‘”]\s\*,\s\*[‘”]\(.+?)[‘”]\s\*(,|\))/g;
class I18nPlugin {
constructor(languages) {
this.languages = languages;
}
hashOf(compilation, content) {
const {hashFunction, hashDigestLength} = compilation.outputOptions;
return util.createHash(hashFunction).update(content).digest(‘hex’).slice(0, hashDigestLength);
}
emitLocalizedChunks(compilation) {
const templates = compilation
.getAssets()
.filter((a) => a.name.endsWith(‘.js’) && a.name.includes(‘[lang]’));
for (const asset of templates) {
const code = asset.source.source();
for (const [lang, dict] of Object.entries(this.languages)) {
const localized = code.replace(MARKER, (\_m, keyset, key) =>
JSON.stringify(dict[keyset][key])
);
const hash = this.hashOf(compilation, localized);
const name = asset.name.replace(asset.info.chunkhash, hash).replace(‘[lang]’, lang);
compilation.emitAsset(name, new sources.RawSource(localized), {
i18n: {originalName: asset.name, originalHash: asset.info.chunkhash, hash, lang}
});
}
}
}
updateEntryFiles(compilation, compiler) {
const entryDir = path.dirname(compiler.options.output.filename);
const localized = compilation.getAssets().filter((a) => a.info.i18n && a.name.endsWith(‘.js’));
const entries = localized.filter((a) => path.dirname(a.name) === entryDir);
for (const entry of entries) {
let code = entry.source.source();
for (const chunk of localized) {
if (chunk.info.i18n.lang === entry.info.i18n.lang) {
code = code.replaceAll(\`”${chunk.info.i18n.originalHash}”\`, \`”${chunk.info.i18n.hash}”\`);
}
}
compilation.updateAsset(entry.name, new sources.RawSource(code));
const hash = this.hashOf(compilation, code);
const newName = entry.name.replace(entry.info.i18n.hash, hash);
compilation.renameAsset(entry.name, newName);
compilation.updateAsset(newName, (s) => s, {i18n: {…entry.info.i18n, hash}});
}
}
updateManifest(compilation) {
const manifest = compilation.getAssets().find((a) => a.name.endsWith(‘paths-map.json’));
if (!manifest) return;
const finalName = {};
for (const a of compilation.getAssets()) {
if (a.name.endsWith(‘.js’) && a.info.i18n) {
(finalName[a.info.i18n.originalName] ||= {})[a.info.i18n.lang] = a.name;
}
}
const paths = JSON.parse(manifest.source.source());
for (const [lang, entry] of Object.entries(paths)) {
if (entry.js && finalName[entry.js]?.[lang]) {
entry.js = finalName[entry.js][lang];
}
}
compilation.updateAsset(manifest.name, new sources.RawSource(JSON.stringify(paths)));
}
apply(compiler) {
compiler.hooks.thisCompilation.tap(‘I18nPlugin’, (compilation) => {
compilation.hooks.processAssets.tap(
{
name: ‘I18nPlugin’,
stage: compilation.PROCESS_ASSETS_STAGE_DERIVED,
additionalAssets: () => this.updateManifest(compilation)
},
() => {
this.emitLocalizedChunks(compilation);
this.updateEntryFiles(compilation, compiler);
}
);
});
}
}
The manifest (paths-map.json) is a map of locale showing which files to load, read by the SSR server to know which asset paths to inject into HTML for a given locale. It’s emitted by a separate statistics plugin at a later sub-stage, which is why updateManifest runs from additionalAssets and silently no-ops until the manifest appears. Every js path in it needs updating from the template name to the final content-hashed name; css stays unchanged.
Existing Solutions and Why we Didn’t use Them
Lingui: Build-time macros, catalog extraction, compilation — but the key detail is that by default Lingui does not bake translations into the bundle. It loads compiled catalogs at runtime via dynamic import per locale. One shared app bundle plus one catalog chunk per language — the same runtime model, just with ICU pre-compilation. Per-locale bundles were requested and closed as wontfix; a baking RFC is open and unimplemented. Getting true baked output from Lingui means fighting its defaults.
FormatJS / react-intl: It can precompile ICU to AST and drop the ICU parser from the bundle — a meaningful size win. However, that only eliminates parsing. Key resolution and formatting still happen in the browser on every render. Runtime, just cheaper.
webpack-localize-assets-plugin: The closest analog to our approach. Inserts placeholder markers; after minification, duplicates assets and replaces markers with locale strings. Same idea exactly. However, it’s been slow-moving (last release early 2023), and you have to adopt its placeholder model and key format wholesale.
Paraglide.js, Intlayer, Meta FBT: The zero-runtime wave, compiler i18n with per-message tree-shaking and almost no runtime code. Ideologically closest to us. However, these are independent localization frameworks with their own message format, macros and opinions about how you write translatable text in source code.
When we wrote our plugin, most of these didn’t exist yet, and the ones that did weren’t mature. The migration question is moot for us — tens of thousands of existing i18n() call sites — but anyone starting fresh today should look at Paraglide first.
When This Logic Inverts
This is not a recommendation to write your own i18n. It’s an analysis of one solution under one set of conditions. Change the conditions and both answers flip.
‘Own’ costs money — you maintain and debug it yourself, onboard people to it and there’s no Stack Overflow thread or prior art to reference. A small plugin is a small but permanent maintenance tax.
For most teams, the right answer is the opposite. If the locale list is not fixed before release; if translations need to change without a rebuild; if language switching without a page reload is required; if the team doesn’t control the final asset pipeline — runtime resolution with a ready-made package is simpler and cheaper to maintain long term. Don’t repeat our solution without repeating our conditions.
Our conditions were specific, and each pushed in the same direction: Fixed locale list, strict immutable-filename caching requirements, an existing mature pipeline with tens of thousands of call sites. Remove any one of these and the balance tips toward runtime and a packaged solution.
Takeaways
When you next hear “why not just use a library” — for i18n or anything else — split it into two separate questions:
- Where should the work happen? For i18n: When does translation resolution occur — in the browser, on the server or at build time? The answer is determined by one fact: Is the locale list fixed before release? If fixed, push the work as early as possible, to build time.
- The main benefit of build-time is build time. Separate-build cost grows linearly with locale count; compile-once-then-inline cost stays nearly flat. Compile and minify once, produce localized bundles from that output.
- The complexity is not in the strings; it’s in the hashes. Any content substitution after compilation must recalculate the content hash and propagate the new name through the full chain — chunk, entry file, manifest. This is the only genuinely tricky part, and it’s where silent production bugs hide.
- Who writes the mechanism is a separate decision. A ready-made package wins by default — until the cost of migrating to it, fighting its runtime defaults and working around missing capabilities outweighs the cost of owning it yourself. That crossover is more likely the smaller the mechanism is and the more existing code you’d have to bend around the package.

