Decoupling translation releases from code deployments
Translation changes are among the most frequent edits in a product. A button label gets sharpened, an error message needs rewording, a new locale ships to a regional market. In traditional setups, every one of those changes requires the same machinery as a code deployment: a branch, a build, a release pipeline, a coordinated rollout window.
That coupling is the root cause of a recurring frustration on product and engineering teams: copy that could be updated in five minutes waits days because it is entangled with a code release cycle. Messagevisor breaks that coupling.
The conventional problem#
In a typical application, translations live inside the application bundle. When you build and ship the app, you ship the translations. This means:
- A legal team that needs an emergency disclaimer update must wait for the next release window.
- A product manager who wants to soften error copy before a big campaign cannot act independently.
- A single mistranslation that slips through to production requires a hotfix deployment to fix.
- Different platforms - web, iOS, Android - each carry their own translation snapshots, so the same product speaks differently across surfaces.
Some teams move translations to a hosted platform to solve the immediacy problem, but they trade coupling for loss of review discipline: changes apply directly to production with no PR, no test run, no rollback path.
How Messagevisor decouples translations from deployments#
Messagevisor's model is: translations are authored as YAML files in a Git repository, built into JSON datafiles as a CI step, and served from a CDN or object storage that your applications fetch at runtime. The application bundle has no translations baked in. It only has an SDK and a URL.
┌──────────────────────────────┐│ Messagevisor repository ││ (YAML definitions + tests) │└────────────┬─────────────────┘ │ PR merged → CI builds ▼┌──────────────────────────────┐│ CDN / object storage ││ messagevisor-web-en-US.json ││ messagevisor-web-nl-NL.json │└────────────┬─────────────────┘ │ SDK fetches at startup + polls for updates ▼┌──────────────────────────────┐│ Running application ││ (no translations baked in) │└──────────────────────────────┘With this architecture, updating a translation is:
- Open a pull request against the Messagevisor repository.
- CI runs lint and tests.
- Reviewer approves and merges.
- CI builds new datafiles and publishes them to the CDN.
- Running applications pick up the new files at their next poll interval.
No application deployment required. No release window. No coordinated rollout.
Setting up the pipeline#
A minimal GitHub Actions workflow that validates and publishes on merge looks like this:
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 - run: npx messagevisor build --showSize 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 - run: aws s3 sync datafiles/ s3://your-cdn-bucket/messagevisor/ env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}Pull requests only run validate. The publish step runs only on merges to main. This means every translation change goes through CI and code review before it reaches any end user.
Loading datafiles at runtime#
On the application side, the SDK fetches the datafile from its published location:
import { createMessagevisor } from "@messagevisor/sdk";const datafile = await fetch( "https://cdn.yoursite.com/messagevisor/messagevisor-web-en-US.json").then((res) => res.json());const m = createMessagevisor({ datafile, locale: "en-US", context: { platform: "web" },});When you want to pick up updates without requiring a page reload, poll for a fresh datafile and call setDatafile():
// Refresh the datafile every 5 minutessetInterval(async () => { const fresh = await fetch( "https://cdn.yoursite.com/messagevisor/messagevisor-web-en-US.json" ).then((res) => res.json()); m.setDatafile(fresh, true);}, 5 * 60 * 1000);You can check getRevision() before calling setDatafile() to skip unnecessary updates if the revision has not changed. Passing true replaces the previously loaded datafile for that locale instead of merging into it.
The rollback story#
Because every state of the Messagevisor repository corresponds to a Git commit, rolling back a bad translation is as simple as rolling back any code change:
$ git revert <commit-sha>After the revert is merged and CI publishes the updated datafiles, applications pick up the corrected content on their next poll. There is no emergency database edit, no hotfix branch for the application, no release window coordination. The fix ships as fast as any other translation change.
Separation of release timelines#
This model means your translation repository operates on its own release timeline, completely separate from your application release cycles:
- Product teams can update copy in the morning and see it live before lunch.
- Legal can push compliance copy updates independently.
- Marketing can push campaign copy for a specific launch time by merging at the right moment.
- Engineers deploying a new application version do not need to coordinate with the translations team because the app always fetches the latest datafile.
Multiple applications - web, mobile, backend - can all point at the same generated datafiles. When translations update, every surface reflects the change simultaneously.
What validation the pull request provides#
The separation only works well if the pull request workflow provides meaningful validation. Messagevisor's CI step gives you:
- Lint: validates YAML structure, field types, required fields, and cross-references between messages, segments, and locales.
- Tests: runs authored assertions against the actual evaluation engine, so a broken ICU pattern or a wrong override condition is caught before merge.
- Build: confirms that all target and locale combinations produce valid datafiles, and
--showSizeflags unexpected size growth.
This means the speed of decoupled releases is not purchased by giving up quality gates.
React integration#
In a React application, the same pattern applies. Load the datafile at app startup, pass it to the provider, and the rest of your component tree can use hooks without knowing where the data came from:
import { createMessagevisor } from "@messagevisor/sdk";import { MessagevisorProvider } from "@messagevisor/react";async function loadDatafile(locale: string) { return fetch( `https://cdn.yoursite.com/messagevisor/messagevisor-web-${locale}.json` ).then((res) => res.json());}export async function AppRoot() { const datafile = await loadDatafile("en-US"); const m = createMessagevisor({ datafile, locale: "en-US", context: { platform: "web" }, }); return ( <MessagevisorProvider instance={m}> <App /> </MessagevisorProvider> );}When the user switches locale at runtime, call setLocale() and setDatafile() together:
import { useMessagevisor } from "@messagevisor/react";function LocaleSwitcher() { const { setLocale } = useMessagevisor(); async function handleSwitch(newLocale: string) { const fresh = await fetch( `https://cdn.yoursite.com/messagevisor/messagevisor-web-${newLocale}.json` ).then((res) => res.json()); // Update both at once so reactive hooks re-render with consistent state setLocale(newLocale); // setDatafile is called via the imperative SDK instance } return ( <select onChange={(e) => handleSwitch(e.target.value)}> <option value="en-US">English</option> <option value="nl-NL">Nederlands</option> <option value="de-DE">Deutsch</option> </select> );}Keeping datafiles small with targets#
Each application only needs a slice of the total translation set. Use targets to ensure each application loads only the messages it uses:
description: Web applicationincludeMessages: - auth* - nav* - billing* - common*excludeMessages: - admin* - internal*locales: - en - en-US - nl-NLcontext: platform: webA target that includes 300 messages instead of 3000 means a significantly smaller datafile that loads faster and parses faster on every page load. The --showSize flag on build shows the impact of your target configuration.

