Bridge Training
i18n

Serving Translations

Build helpers that copy locale files into a Vite app and version their URLs by content hash.

Apps that load translations at runtime with i18next-http-backend (apps/app, apps/player) fetch one JSON file per language and namespace from <base>locales/<lang>/<namespace>.json. Two sub-paths cover both sides of that contract:

Import PathRuns inExports
@workspace/i18n/viteNode (Vite config)createI18nCopyTargets, createI18nLocaleHashes
@workspace/i18n/load-pathBrowsercreateI18nLoadPath, I18N_LOCALES_DIR, toI18nLocaleHashKey, I18nLocaleHashes

The source files live in @workspace/i18n-messages (src/locales/<namespace>/<lang>.json) — see i18n Messages.

How It Fits Together

  1. At build time, createI18nCopyTargets tells vite-plugin-static-copy to copy each <localesDir>/<lang>.json to locales/<lang>/<namespace>.json in the output.
  2. createI18nLocaleHashes hashes the same files (SHA-256, first 10 hex characters) and the Vite config injects the table as the __I18N_LOCALE_HASHES__ global.
  3. At runtime, createI18nLoadPath builds each URL and appends ?v=<hash>, so a changed file gets a new URL and stale caches are bypassed.

A locale file served at a fixed URL would stay in the browser cache after a deploy, and users would see raw keys until the cache was purged by hand. With the hash, a file whose content did not change keeps its URL (and its cache entry), and there is no manual version to bump.

In dev the hash table is empty, so URLs are requested unversioned and a dev-server middleware serves them straight from packages/i18n-messages.

Vite Configuration

Declare the locale sources once and share them between the copy targets and the hashes:

apps/app/vite.config.ts
import {
  createI18nCopyTargets,
  createI18nLocaleHashes,
} from "@workspace/i18n/vite";
import { defineConfig } from "vite";
import { viteStaticCopy } from "vite-plugin-static-copy";

const I18N_LOCALES = {
  languages: ["fr", "en"],
  packages: [
    {
      localesDir: "../../packages/i18n-messages/src/locales/common",
      namespace: "common",
    },
    {
      localesDir: "../../packages/i18n-messages/src/locales/primitives",
      namespace: "primitives",
    },
    // ...one entry per namespace
  ],
};

export default defineConfig(({ command }) => ({
  define: {
    __I18N_LOCALE_HASHES__: JSON.stringify(
      command === "build"
        ? createI18nLocaleHashes({ root: import.meta.dirname, ...I18N_LOCALES })
        : {},
    ),
  },
  plugins: [
    viteStaticCopy({
      targets: [...createI18nCopyTargets(I18N_LOCALES)],
    }),
  ],
}));

Declare the global for TypeScript, and define it in the Vitest config too:

src/vite-env.d.ts
declare const __I18N_LOCALE_HASHES__: import("@workspace/i18n/load-path").I18nLocaleHashes;
vitest.config.ts
define: {
  __I18N_LOCALE_HASHES__: "{}",
},
  • vitest.config.ts does not load vite.config.ts: without that define, any test importing the i18n instance throws a ReferenceError.
  • If the app's Biome config enables noUndeclaredVariables, list the global under javascript.globals.

createI18nCopyTargets

Returns vite-plugin-static-copy targets, one per language × package.

OptionTypeDescription
languagesreadonly string[]Languages to copy
packages{ localesDir: string; namespace: string; extension?: string }[]localesDir is relative to the Vite root; namespace becomes the copied file name; extension defaults to ".json"

createI18nLocaleHashes

Same options plus root (the directory localesDir paths are relative to, usually import.meta.dirname). Reads every file synchronously and returns an I18nLocaleHashes table keyed by toI18nLocaleHashKey(language, namespace) ("<lang>/<namespace>").

i18next Configuration

apps/app/src/lib/i18n/instance.ts
import { createI18nLoadPath } from "@workspace/i18n/load-path";
import Backend from "i18next-http-backend";

instance.use(Backend).init({
  backend: {
    loadPath: createI18nLoadPath({
      basePath: import.meta.env.BASE_URL,
      hashes: __I18N_LOCALE_HASHES__,
    }),
  },
  ns: ["common", "errors", "primitives", "rotation", "rich-editor" /* ... */],
  defaultNS: "common",
});

createI18nLoadPath

OptionTypeDescription
basePathstringPublic base the locales/ directory is served under (e.g. /, /player/)
hashesI18nLocaleHashesBuild-time hash table; a file without an entry is requested unversioned

Returns a (languages, namespaces) => string function for backend.loadPath:

const loadPath = createI18nLoadPath({
  basePath: "/",
  hashes: { "fr/common": "3f9a1c0b2e" },
});

loadPath(["fr"], ["common"]); // "/locales/fr/common.json?v=3f9a1c0b2e"
loadPath(["en"], ["common"]); // "/locales/en/common.json"

Adding a Namespace to an App

  1. Add a { localesDir, namespace } entry to the app's I18N_LOCALES
  2. Add the namespace to ns in the i18next init