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.

Four failures of an unstructured shared catalogue Without a package boundary each application creates its own resolver instance, so keys resolve in one application and not another. Catalogues get copied rather than shared, so a fix reaches one consumer. Build task inputs are too broad, so caching never hits. And with no version boundary a catalogue change forces every application to rebuild together. What goes wrong without the split Symptom Cause Two resolvers keys resolve in one app only each app created its own instance Stale strings a fix ships to one app catalogues copied, not shared Cache never hits every build re-extracts task inputs include the whole repo One app blocks all a locale bump breaks the pipeline no version boundary between apps
Each is usually diagnosed as a build problem; all four are boundary problems.

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.

Package layout for shared localization in a monorepo A runtime package holds the resolver and its configuration and ships no strings. A messages package holds the catalogues with one export per locale and is the versioned artifact. A tooling package holds extraction, gates and the pseudo transform and is a development dependency only. Applications depend on the runtime and never read catalogue files directly. The four packages a shared catalogue needs @acme/i18n-runtime the resolver and its config — no strings one instance @acme/i18n-messages the catalogues, one export per locale versioned @acme/i18n-tooling extraction, gates, pseudo transform dev only apps/* consume the runtime, never the files many
The rule that makes this work: applications import the runtime, never the message files.

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
}
A single extraction feeding every application Extraction runs once across every application and library in the workspace and produces one source catalogue. Translation fills the target catalogues, and the result is published as a versioned package that every application consumes, so a string appears exactly once regardless of how many applications render it. One extraction, many consumers Apps and libraries t() call sites extract One source catalogue i18n-messages/en translate Target catalogues per locale publish Versioned package consumed by every app Extraction runs once across the whole workspace — a per-app extractor produces per-app duplicates of the same string.
Extract at the workspace level; extracting per application is how the same string gets paid for twice.

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.*"]
    }
  }
}
Task inputs that make the cache correct Extraction depends on source files and the extractor configuration but not on target catalogues. Compilation depends on both source and target catalogues but not on application code. The coverage gate depends on catalogues and thresholds. The application build depends on its own source and the compiled catalogues, not on the raw files. Getting task caching right Inputs must include Must not include extract source files, extractor config target catalogues compile catalogues source + target catalogues app source gate coverage catalogues, thresholds app source build app app source, compiled catalogues raw catalogue files
Over-declaring inputs is why a caching build system never hits — and it always looks like a caching bug.

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.

Part of Translation Workflows & CI/CD.