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 Path | Runs in | Exports |
|---|---|---|
@workspace/i18n/vite | Node (Vite config) | createI18nCopyTargets, createI18nLocaleHashes |
@workspace/i18n/load-path | Browser | createI18nLoadPath, 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
- At build time,
createI18nCopyTargetstellsvite-plugin-static-copyto copy each<localesDir>/<lang>.jsontolocales/<lang>/<namespace>.jsonin the output. createI18nLocaleHasheshashes the same files (SHA-256, first 10 hex characters) and the Vite config injects the table as the__I18N_LOCALE_HASHES__global.- At runtime,
createI18nLoadPathbuilds 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:
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:
declare const __I18N_LOCALE_HASHES__: import("@workspace/i18n/load-path").I18nLocaleHashes;define: {
__I18N_LOCALE_HASHES__: "{}",
},vitest.config.tsdoes not loadvite.config.ts: without thatdefine, any test importing the i18n instance throws aReferenceError.- If the app's Biome config enables
noUndeclaredVariables, list the global underjavascript.globals.
createI18nCopyTargets
Returns vite-plugin-static-copy targets, one per language × package.
| Option | Type | Description |
|---|---|---|
languages | readonly 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
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
| Option | Type | Description |
|---|---|---|
basePath | string | Public base the locales/ directory is served under (e.g. /, /player/) |
hashes | I18nLocaleHashes | Build-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
- Add a
{ localesDir, namespace }entry to the app'sI18N_LOCALES - Add the namespace to
nsin the i18nextinit