Skip to content

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 ​

ts
import { createI18n } from '@loewen-digital/fullstack/i18n'

Basic usage ​

ts
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.

ts
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).

ts
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.

ts
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.

ts
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.

OptionTypeDefaultDescription
localestring'en'Active locale
fallbackstring'en'Locale tried when a key is missing in the active one
messagesRecord<locale, messages>{}Message catalogs, flat or nested

directory is in the config type but not read by createI18n; call loadTranslations yourself.