@coherent.js/i18n
Internationalization for Coherent.js applications: translations with interpolation and pluralization, locale-aware formatters, and locale management.
pnpm add @coherent.js/i18nTranslations
Create a translator, add messages per locale, and translate with t().
Interpolation uses {{name}} placeholders by default:
import { createTranslator } from '@coherent.js/i18n';
const translator = createTranslator({ defaultLocale: 'en', fallbackLocale: 'en' });
translator.addTranslations('en', {
common: {
welcome: 'Welcome',
hello: 'Hello, {{name}}!'
}
});
translator.addTranslations('fr', {
common: {
welcome: 'Bienvenue',
hello: 'Bonjour, {{name}} !'
}
});
translator.t('common.hello', { name: 'Ada' }); // "Hello, Ada!"
translator.setLocale('fr');
translator.t('common.welcome'); // "Bienvenue"Keys are dot-paths into the translation object; only the translations' own
properties are looked up, so t('constructor') is a missing key like any
other. Missing keys fall back to fallbackLocale, then to the key itself
(customizable via missingKeyHandler).
Locales resolve to the closest loaded one: after setLocale('fr-FR') with only
fr loaded, getLocale() is 'fr', and a key missing from fr-CA is looked
up in fr before the fallback locale. Params are inserted literally (a $ or
a {{placeholder}} inside a value is not expanded).
Server-side rendering: one locale per request
setLocale() changes the translator's current locale for everyone using it. On
a server, bind a translator to each request with forLocale(), which never
touches the shared locale:
const i18n = createTranslator({ defaultLocale: 'en', fallbackLocale: 'en' });
// ...addTranslations() once at startup
app.get('/', (req, res) => {
const { t, locale } = i18n.forLocale(req.acceptsLanguages('en', 'fr') || 'en');
res.send(render({ html: { lang: locale, children: [{ body: { children: [{ p: { text: t('common.welcome') } }] } }] } }));
});forLocale(locale, { escape }) returns { locale, t, has, getLocale }.
Escaping params
text: is escaped by core, so it is the safe place for translations. When a
translation contains markup and goes through html:, escape the params (the
template itself is trusted):
translator.addTranslations('en', { joined: '<strong>{{name}}</strong> joined' });
{ p: { html: translator.t('joined', { name: userInput }, { escape: true }) } }createTranslator({ escape: true }) escapes params in every call; the third
argument of t() is a locale string or { locale, escape }.
Scoped translators
createScopedTranslator(translator, namespace) prefixes every key, which keeps
component code short:
import { createScopedTranslator } from '@coherent.js/i18n';
const { t } = createScopedTranslator(translator, 'common'); // { t, has, getLocale, setLocale }
t('hello', { name: 'Ada' }); // same as translator.t('common.hello', ...)Formatters
Locale-aware date, number, currency, and list formatting built on Intl:
import { createFormatters } from '@coherent.js/i18n';
const fmt = createFormatters('en-US');
fmt.date.format(new Date()); // "1/15/2026"
fmt.number.format(12345.678); // "12,345.678"
fmt.currency.format(1999.99); // "$1,999.99"Individual formatter classes (DateFormatter, NumberFormatter,
CurrencyFormatter, ListFormatter) are also exported. fmt.date.relative(date)
picks the largest fitting unit in either direction ("in 2 days", "last week",
"3 months ago").
Locale management
import {
createLocaleManager,
detectLocale,
normalizeLocale,
isRTL,
getLocaleDirection
} from '@coherent.js/i18n';
const locales = createLocaleManager({ defaultLocale: 'en-US' });
locales.getLocale(); // current locale
detectLocale(); // best-guess locale for the environment
normalizeLocale('EN_us'); // "en" (pass true as second argument to keep the region: "en-us")
isRTL('ar'); // true
getLocaleDirection('he'); // "rtl"Using translations in components
Components are plain functions, so pass the translator (or a scoped t) like
any other dependency:
export function Greeting({ t, name }) {
return { p: { text: t('hello', { name }) } };
}