i18n
createI18n looks up messages by key in the active locale, falls back to another locale and then to the key itself, interpolates :name parameters, picks plural forms by count, and formats numbers and dates with Intl. Messages are plain objects, flat or nested; loadTranslations reads them from JSON files.
Import
import { createI18n } from '@loewen-digital/fullstack/i18n'Basic usage
import { createI18n } from '@loewen-digital/fullstack/i18n'
const messages = {
en: {
welcome: 'Welcome, :name!',
items: 'no items|one item|:count items',
auth: { errors: { invalid: 'Invalid email or password.' } },
},
de: {
welcome: 'Willkommen, :name!',
items: 'keine Einträge|ein Eintrag|:count Einträge',
},
}
const i18n = createI18n({ locale: 'de', fallback: 'en', messages })
i18n.t('welcome', { name: 'Alice' }) // 'Willkommen, Alice!'
i18n.t('auth.errors.invalid') // 'Invalid email or password.' from the fallback
i18n.t('missing.key') // 'missing.key'Keys use dot notation into nested objects; a flat key with dots in it ('auth.errors.invalid': '...') works as well. A parameter that is not passed stays as :name in the output.
Plural forms
tn(key, count, params?) splits the message at | and picks a form: two forms are one|other (count === 1 takes the first), three are zero|one|other. :count is replaced in the chosen form.
i18n.tn('items', 0) // 'keine Einträge'
i18n.tn('items', 1) // 'ein Eintrag'
i18n.tn('items', 7) // '7 Einträge'Locales
locale(name) switches the instance; getLocale() reads it. An instance is one active locale, so a server that serves several languages creates an instance per request (they are cheap: the messages object is shared by reference).
export function i18nFor(request: Request) {
const preferred = request.headers.get('accept-language')?.split(',')[0]?.slice(0, 2)
return createI18n({ locale: preferred && preferred in messages ? preferred : 'en', fallback: 'en', messages })
}Numbers and dates
number and date are Intl.NumberFormat and Intl.DateTimeFormat for the active locale, with their options passed through. date takes a Date, a timestamp or a date string.
i18n.number(1234567.89) // '1.234.567,89'
i18n.number(0.42, { style: 'percent' }) // '42 %'
i18n.number(9.99, { style: 'currency', currency: 'EUR' }) // '9,99 €'
i18n.date(new Date(2026, 3, 5)) // '5.4.2026'
i18n.date('2026-04-05T15:45:00Z', { dateStyle: 'long' }) // '5. April 2026'
i18n.date(Date.now(), { timeStyle: 'short' })Loading messages from files
loadTranslations(directory) reads every <locale>.json in a directory (Node only) into the messages shape.
import { createI18n, loadTranslations } from '@loewen-digital/fullstack/i18n'
async function fromFiles() {
const messages = await loadTranslations('./locales') // en.json, de.json, ...
return createI18n({ locale: 'en', messages })
}Config options
createI18n(config) reads these; every option has a default.
| Option | Type | Default | Description |
|---|---|---|---|
locale | string | 'en' | Active locale |
fallback | string | 'en' | Locale tried when a key is missing in the active one |
messages | Record<locale, messages> | {} | Message catalogs, flat or nested |
directory is in the config type but not read by createI18n; call loadTranslations yourself.