Multi-brand or multi-product translations in one repository
Organizations that operate multiple brands or products face a translation management problem that sits between two extremes. Maintaining completely separate repositories per brand means duplicating infrastructure, CI pipelines, and shared content like common UI strings. Merging everything into one flat namespace means brand-specific copy is mixed with shared copy in ways that make ownership and review difficult.
Messagevisor's namespace structure and target system give you a middle path: one repository, shared infrastructure, isolated namespaces per brand, and purpose-built datafiles that each brand's applications can consume without seeing other brands' content.
Structuring namespaces by brand#
Messagevisor message keys are derived from file paths. A well-chosen directory structure creates natural brand isolation:
messages/├── common/ # Shared across all brands│ ├── nav/│ ├── errors/│ └── forms/├── brand-alpha/ # Exclusive to Brand Alpha│ ├── landing/│ ├── onboarding/│ └── checkout/├── brand-beta/ # Exclusive to Brand Beta│ ├── landing/│ ├── dashboard/│ └── billing/└── internal/ # Internal tooling, never shipped to end users └── admin/Message keys then look like:
common.nav.home- shared across brandsbrand-alpha.landing.headline- exclusive to Brand Alphabrand-beta.billing.invoiceTitle- exclusive to Brand Beta
This structure makes CODEOWNERS governance straightforward:
messages/brand-alpha/ @brand-alpha-teammessages/brand-beta/ @brand-beta-teammessages/common/ @platform-teamPer-brand targets#
Each brand gets its own target that includes only the messages it needs:
description: Brand Alpha web applicationincludeMessages: - common* - brand-alpha*excludeMessages: - internal*locales: - en - en-US - nl-NL - de-DEcontext: platform: web brand: brand-alphadescription: Brand Beta web applicationincludeMessages: - common* - brand-beta*excludeMessages: - internal*locales: - en - en-US - fr-FRcontext: platform: web brand: brand-betadescription: Brand Beta mobile applicationincludeMessages: - common* - brand-beta* - brand-beta.notifications*excludeMessages: - internal*locales: - en - en-US - fr-FRcontext: platform: mobile brand: brand-betaThe includeMessages filter ensures that Brand Alpha's datafile never contains Brand Beta's messages, and vice versa. Each brand's application loads only its own artifact.
Shared messages with brand-specific overrides#
Some messages share a common base but need brand-specific variants. Rather than duplicating the message under each brand's namespace, keep the shared base and use overrides to customize per brand:
description: Home navigation label used across all brandstranslations: en: Home nl: Home de: Startseite fr: Accueiloverrides: - key: brand-alpha conditions: attribute: brand operator: equals value: brand-alpha translations: en: Dashboard - key: brand-beta conditions: attribute: brand operator: equals value: brand-beta translations: en: Overview fr: Vue d'ensembleWhen the brand-alpha-web target is built with context: { brand: brand-alpha }, the builder evaluates the overrides against the known context. The brand-beta override is an impossible branch for this target and is stripped from the datafile. Brand Alpha's web artifact contains only "Dashboard" for the home label.
Brand-exclusive messages#
For messages that exist only for one brand and have no shared base, author them entirely under the brand's namespace:
description: Brand Alpha landing page main headlinemeta: owner: brand-alpha-team surface: landing-pagetranslations: en: The platform built for your team nl: Het platform dat is gebouwd voor jouw team de: Die Plattform für Ihr Teamdescription: Brand Beta landing page main headlinemeta: owner: brand-beta-team surface: landing-pagetranslations: en: Collaborate faster with Brand Beta fr: Collaborez plus vite avec Brand BetaNo overlap, no risk of one brand's headline appearing in the other brand's application.
Locale configurations per brand#
Brands serving different markets may need different locale configurations. You can define brand-specific format presets by creating locale files that reflect each brand's market:
description: English (United States)direction: ltrinheritTranslationsFrom: eninheritFormatsFrom: enformats: number: money: style: currency currency: USDBrand Beta operates in Europe, so its French locale needs EUR formatting:
description: French (France)direction: ltrformats: number: money: style: currency currency: EUR currencyDisplay: symbol date: long: year: numeric month: long day: numericEach brand's target specifies which locales to include. Brand Alpha targets en-US, nl-NL, and de-DE. Brand Beta targets en-US and fr-FR. The locale files are shared infrastructure - each brand's target cherry-picks the locales it needs.
Build output per brand#
Build produces one file per target and locale combination:
$ npx messagevisor build --showSizedatafiles/├── messagevisor-brand-alpha-web-en-US.json├── messagevisor-brand-alpha-web-nl-NL.json├── messagevisor-brand-alpha-web-de-DE.json├── messagevisor-brand-beta-web-en-US.json├── messagevisor-brand-beta-web-fr-FR.json├── messagevisor-brand-beta-mobile-en-US.json└── messagevisor-brand-beta-mobile-fr-FR.jsonEach brand's application loads only its own datafiles. The Brand Alpha web app loads messagevisor-brand-alpha-web-en-US.json. The Brand Beta mobile app loads messagevisor-brand-beta-mobile-fr-FR.json. They never overlap.
Testing brand isolation#
Write target tests to assert that brand isolation is working correctly - that Brand Alpha's datafile never contains Brand Beta's messages:
target: brand-alpha-webassertions: - description: Common nav messages are included expectedToIncludeMessages: - common.nav.home - common.errors.notFound - description: Brand Alpha landing messages are included expectedToIncludeMessages: - brand-alpha.landing.headline - description: Brand Beta messages are excluded expectedToNotIncludeMessages: - brand-beta.landing.headline - brand-beta.billing.invoiceTitle - description: Internal messages are excluded expectedToNotIncludeMessages: - internal.admin.dashboardtarget: brand-beta-webassertions: - description: Brand Beta overview copy resolves in French locale: fr-FR message: common.nav.home expectedTranslation: Vue d'ensemble - description: Brand Alpha exclusive messages are excluded expectedToNotIncludeMessages: - brand-alpha.landing.headline - brand-alpha.onboarding.step1Shared CI, per-brand publish#
A single CI workflow can build all brand targets and publish each to the right location:
name: Messagevisoron: pull_request: push: branches: [main]jobs: validate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor lint - run: npx messagevisor test build-and-publish: runs-on: ubuntu-latest needs: validate if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx messagevisor build - name: Publish Brand Alpha datafiles run: | aws s3 sync datafiles/ s3://brand-alpha-cdn/messagevisor/ \ --include "messagevisor-brand-alpha-*" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - name: Publish Brand Beta datafiles run: | aws s3 sync datafiles/ s3://brand-beta-cdn/messagevisor/ \ --include "messagevisor-brand-beta-*" env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
