Monorepo i18n Package Architecture
Three applications and a component library share a repository. Each one has an i18n/ directory. The same “Save” string is defined in four places, translated four times, and worded slightly differently in German in two of them. A fix to a message reaches one application. The build system never caches anything localization-related, because every task declares the whole repository as its input.
None of that is a monorepo problem. It is what happens when a shared concern has no package boundary, and localization is a shared concern in almost every workspace that has more than one deployable.
This page sits in Translation Workflows & CI/CD because the packaging decision is what determines whether the extraction, sync and gating described there run once or once per application.
Prerequisites
Concept & spec — three packages, one instance
The architecture that holds up separates three things that are usually conflated: the code that resolves strings, the strings themselves, and the tools that manage them.
The runtime package owns the resolver, its configuration and the framework bindings. It contains no message content at all. Its job is to guarantee that every consumer in the workspace shares one resolver implementation and — critically — one instance of it at runtime, since two instances is the failure that makes keys resolve in one application and not another.
The messages package owns the catalogues. It is the artifact that translation flows in and out of, the thing that gets versioned, and the only place a string exists. Its exports are per locale and per namespace, so a consumer can import exactly what it renders.
The tooling package owns extraction, the CI gates, and any transform such as the pseudo-locale generator from localization testing and pseudolocalization. It is a development dependency, never shipped, and having it in one place is what stops three applications drifting into three slightly different extractor configurations.
The rule binding them together is that applications depend on the runtime and never read catalogue files directly. An application that imports a JSON file from the messages package bypasses the resolver, defeats lazy loading, and pins itself to a file layout that then cannot change.
Step-by-step implementation
1. Extract once, across the whole workspace
A per-application extractor produces per-application catalogues, which is how the same string is paid for four times. The extractor should scan every application and library and emit one source catalogue.
// packages/i18n-tooling/extract.config.json
{
"input": ["../../apps/*/src/**/*.{ts,tsx}", "../../packages/ui/src/**/*.tsx"],
"output": "../i18n-messages/src/locales/$LOCALE/$NAMESPACE.json",
"defaultNamespace": "common",
"keepRemoved": true
}
2. Give the messages package explicit, granular exports
Export map entries per locale and namespace let bundlers tree-shake and let the runtime load lazily. A single barrel export defeats both.
// packages/i18n-messages/package.json
{
"name": "@acme/i18n-messages",
"version": "3.4.0",
"exports": {
"./locales/*": "./dist/locales/*.js",
"./manifest": "./dist/manifest.js"
},
"sideEffects": false
}
3. Make the runtime own the instance
// packages/i18n-runtime/src/index.ts
let instance: Resolver | undefined;
export function getI18n(): Resolver {
// One instance per process, regardless of how many packages import this.
instance ??= createResolver({ fallbackLocale: 'en', missingKeyHandler });
return instance;
}
The ??= is doing real work in a workspace: without it, two packages that both initialise the resolver produce two catalogues, and which one a component sees depends on module resolution order.
4. Declare narrow task inputs so the cache can hit
Caching is where a monorepo either pays for itself or does not, and localization tasks are frequently the ones that spoil it, because the naive input set is “everything”.
// turbo.json
{
"tasks": {
"i18n:extract": {
"inputs": ["src/**/*.{ts,tsx}", "extract.config.json"],
"outputs": ["../i18n-messages/src/locales/en/**"]
},
"i18n:compile": {
"dependsOn": ["i18n:extract"],
"inputs": ["src/locales/**"],
"outputs": ["dist/**"]
},
"build": {
"dependsOn": ["^i18n:compile"],
"inputs": ["src/**", "!src/**/*.test.*"]
}
}
}
5. Version the messages package deliberately
Because the catalogue is a package, a catalogue change is a version bump, and the version number can carry meaning. A new string is additive and a minor bump. A removed or renamed key is breaking for any consumer still referencing it, and deserves a major — which is the mechanism that stops one team’s cleanup from breaking another team’s build without warning.
Configuration reference
| Decision | Recommended | Why |
|---|---|---|
| Extraction scope | whole workspace, once | Per-application extraction duplicates strings and translation cost. |
| Message package exports | per locale, per namespace | Enables lazy loading and tree-shaking; a barrel export prevents both. |
| Resolver instances | exactly one, owned by the runtime | Two instances is the cause of “resolves in one app only”. |
| Applications importing JSON | forbidden | Bypasses the resolver and pins the file layout. |
| Catalogue versioning | semver, breaking on key removal | Gives consumers a warning boundary. |
| Task inputs | narrow and explicit | Broad inputs are why the cache never hits. |
| Pseudo-locale generation | in the tooling package | One transform, applied identically for every consumer. |
Framework variants
Turborepo. Inputs and outputs are declared per task, and the common mistake is omitting outputs on the extraction task, which makes the result uncacheable and re-runs it on every build. Remote caching multiplies the benefit, and it multiplies the cost of over-broad inputs equally.
Nx. Named inputs make the same declarations reusable across projects, and the dependency graph can enforce the “no direct JSON import” rule with a module-boundary lint rule rather than by convention — which is the difference between a rule and a hope.
pnpm workspaces without a task runner. Workspace protocol dependencies still give you the package boundary, which is most of the value. What is missing is caching, so extraction runs every time; keeping it fast matters more in this setup.
Mixed frameworks in one workspace. A React application and a Vue application can share the messages package but not the runtime bindings. The clean split is a framework-agnostic core in i18n-runtime and thin per-framework adapter packages on top, so the catalogue stays single even when the bindings cannot.
Verification
# One resolver instance, one catalogue version across the workspace
pnpm why @acme/i18n-messages
# expected: a single version, deduped at the workspace root
# No application imports catalogue files directly
grep -rn "i18n-messages/.*\.json" apps/ && exit 1 || echo 'no direct imports'
# The cache actually hits on a no-op build
pnpm turbo run build --dry-run=json | jq '.tasks[] | select(.cache.status != "HIT") | .taskId'
# expected: empty
The middle check is the one worth enforcing permanently. Direct imports appear innocently — someone needs a string in a test, or in a script — and each one is a future obstacle to changing how catalogues are stored.
Ownership across teams
A shared catalogue is a shared dependency, and shared dependencies fail on ownership before they fail on technology. Three arrangements are common and only one of them survives contact with a growing organisation.
No owner. The catalogue belongs to the workspace, which means it belongs to nobody. Conventions decay, namespaces accumulate strings nobody claims, and the coverage number stops being actionable because no single team can move it. This is the default state and it is the one to design away from.
A single localization team owns everything. Every string change goes through one team, which gives consistency and creates a queue. It works while the number of product teams is small and becomes the bottleneck exactly when the product starts moving fastest.
Federated ownership with a shared floor. Each namespace has an owning team, the shared namespace has an explicit owner — usually whoever owns the design system — and one group owns the tooling and the conventions rather than the content. This is the arrangement that scales, and the reason is that it separates two different kinds of decision: what a string says, which is a product decision, and how strings are named and gated, which is a platform decision.
Making that split real requires two mechanisms rather than a document. Code ownership rules on the catalogue directories put the right reviewer on every catalogue diff automatically. And per-namespace coverage thresholds, rather than one repository-wide number, mean a team can see and act on its own gap without being blocked by another team’s.
Adding a new application to the workspace
The value of the packaging shows up most clearly when a fourth application arrives, and the checklist is short enough to write down.
The new application depends on the runtime package and the messages package, and on nothing else localization-related. It declares which namespaces it renders, which is what keeps its bundle proportional. It is added to the extractor’s input list, so its strings flow into the same source catalogue as everyone else’s. And it inherits the gates automatically, because the gates live in the tooling package and run at workspace level.
What it must not do is bring its own resolver, its own extractor configuration, or its own copy of any string. Each of those is a fork, and each looks harmless in the pull request that introduces it.
There is one genuinely new decision: whether the application’s strings belong in a new namespace or in an existing one. The default answer is a new namespace named for the application, because it keeps the bundle boundary clean and gives the new team something to own. Strings move into the shared namespace later, when a second consumer actually renders them — not in anticipation of one.
When the workspace outgrows one catalogue
Not every monorepo should share a single catalogue, and it is worth naming the signal that says yours should not.
The signal is not size. A catalogue with ten thousand keys is fine if the namespaces are clean, because no consumer loads all of it. The signal is divergent release cadence combined with divergent audiences: two products in one repository that share no strings, ship on unrelated schedules, and serve different markets in different languages.
In that case the shared package is coupling without benefit — every catalogue version bump touches a product that gained nothing from it, and the locale lists disagree. Two messages packages, both consuming the same runtime and tooling, keeps the parts that genuinely are shared while letting the content diverge honestly.
The mistake is doing that split early, on the assumption that products will diverge. Shared strings are cheap to keep shared and expensive to reunify, so the correct order is to start with one catalogue and split when the evidence appears — which is the same argument, at a different scale, as the regional split decision in locale-aware SEO and hreflang.
Common pitfalls
- Extracting per application. Produces duplicate strings, duplicate translation cost, and divergent wording for the same concept.
- Two resolver instances. Keys resolve in one consumer and not another, with no error anywhere.
- A barrel export from the messages package. Every consumer pulls every locale, and lazy loading becomes impossible.
- Over-broad task inputs. Every change invalidates every localization task, and the cache never hits.
- Publishing catalogues without versioning. A removed key breaks a consumer with no signal that anything changed.
- Applications importing JSON directly. Bypasses the resolver, pins the layout, and hides usage from the orphan scan.
Migrating from per-application catalogues
Most workspaces reach this architecture from the other direction: three applications that each grew their own i18n/ directory. The migration is mechanical, and the order matters because the intermediate states have to keep working.
Start with the runtime. Extract the resolver setup that each application has duplicated into a runtime package and repoint every application at it, leaving the catalogues exactly where they are. This is the least risky step — no strings move — and it immediately fixes the two-instance class of bug, which is often the reason the migration was proposed in the first place.
Then merge the catalogues, one namespace at a time. For each application, move its strings into a namespace inside the shared messages package, keeping keys unchanged. Because the keys do not change, translations follow: the synchronisation job sees the same identifiers in a new file, which is a move rather than a delete and create — the distinction that decides whether translations survive, described in safely renaming a translation key.
Reconcile duplicates last, and deliberately. Once every application’s strings are in one package, the strings that were defined three times are visible for the first time. Some are genuinely the same and collapse into the shared namespace; some only coincide in English and must stay separate. That judgement is per string and cannot be automated, which is why it is a separate step rather than part of the merge.
Move the tooling at the end. With one catalogue in place, the three extractor configurations can become one, and the gates can move from per-application workflows to a single workspace-level job. Doing this earlier means maintaining a workspace-level extractor against per-application catalogues, which is more work than either end state.
A realistic size for this is one or two sprints for a three-application workspace, most of it in the duplicate reconciliation rather than in the mechanics. The step that pays for the whole exercise is usually the first one.
FAQ
Is a separate messages package worth it for two applications?
Usually yes, because the cost is a package.json and the alternative is copying. The threshold is not the number of applications but whether any string is rendered by more than one of them — the moment that is true, a shared package is cheaper than keeping two copies honest.
Should each application have its own namespaces?
Yes, alongside shared ones. An application-specific namespace keeps its strings out of every other application’s bundle, and the shared namespace holds what genuinely is shared. The boundaries are the ones described in string catalog governance, applied at workspace scale.
How do we handle an application that needs a string urgently?
Add it to the shared catalogue and ship it untranslated behind the fallback chain, rather than adding a local override. A local override is a second source of truth, and it will outlive the urgency.
Does this work with independently deployed applications?
It does, and versioning is what makes it work. Each application pins a catalogue version and upgrades on its own schedule, which is exactly the decoupling that a shared directory cannot provide.
Where should the pseudo-locale be generated?
In the tooling package, as a build step for the messages package. One transform applied once means every consumer tests against identical pseudo output, and no application can accidentally ship it.
Should the messages package be published to a registry?
Only if something outside the workspace consumes it. Inside a workspace the workspace protocol is enough, and publishing adds a release step to every string change. A mobile application or a partner-facing widget living in another repository is the case where publishing earns its keep — and at that point the versioning discipline described above stops being optional.
How do we keep bundle size honest across applications?
Measure it per application, in CI, against the compiled catalogue rather than the source. A shared package makes it easy for one application to pull in namespaces it does not render, and the only reliable signal is the emitted bundle. A size budget per application, failing the build when a namespace appears that the application does not declare, keeps the boundary real.
Related
- Cache misses on locale artifacts — declaring the real task graph so localization stops re-running on every build.
- Two i18n instances in a workspace — the duplication that makes shared components render raw keys.
- Versioning a shared message package — which catalogue changes are breaking, and what a consumer should pin.
- Typed translation keys across packages — a generated key union that turns a typo into a compile error everywhere.
- String Catalog Governance — the naming and namespace rules a shared catalogue depends on.
- Extracting translation keys with i18next-parser — the extraction step that must run at workspace scale.
- GitHub Actions i18n CI Gates — the gates that move into the tooling package.
- Localization Testing & Pseudolocalization — the transform that belongs beside them.
- Crowdin Integration for Dev Teams — syncing one catalogue rather than several.
Part of Translation Workflows & CI/CD.