Internationalization
@basaltkit/i18n resolves the locale from the request context (per user, then per tenant), translates typed message catalogs with interpolation and CLDR plurals, and formats numbers, currency, dates, relative time and lists through the native Intl APIs. Zero dependencies.
Setup
Define one catalog per locale with defineMessages (it pins the key/value types so t() autocompletes), then register them with i18nPlugin:
// src/i18n.ts
import { defineMessages } from '@basaltkit/i18n'
export const en = defineMessages({
greeting: 'Hi {name}',
notes: { one: '{count} note', other: '{count} notes' },
})
export const pt = defineMessages({
greeting: 'Olá {name}',
notes: { one: '{count} nota', other: '{count} notas' },
})// src/app.ts
import { createApp } from '@basaltkit/core'
import { I18n, I18N, i18nPlugin } from '@basaltkit/i18n'
import { en, pt } from './i18n.js'
const app = await createApp({
plugins: [i18nPlugin({ locales: { en, pt }, defaultLocale: 'en' })],
}).boot()
// The I18N token erases the catalog type; cast to keep key autocompletion.
export const i18n = app.container.get(I18N) as I18n<typeof en>Custom locale resolution
By default the request locale is ctx().user.locale, then ctx().tenant.locale. To resolve it another way, pass resolveLocale. Populate a locale key on the request context yourself (e.g. from an Accept-Language header in an enricher) — RequestContext has an open index signature — then read it back here:
import { ctx } from '@basaltkit/core'
i18nPlugin({
locales: { en, pt },
defaultLocale: 'en',
resolveLocale: () => ctx().locale as string | undefined,
})Translate
i18n.in('pt').t('greeting', { name: 'Ada' }) // 'Olá Ada'
i18n.in('en').t('notes', { count: 3 }) // '3 notes' (plural via Intl.PluralRules)Inside a request, i18n.t(...) uses the context locale automatically — ctx().user.locale, then ctx().tenant.locale (store a locale field on those records, or pass a custom resolveLocale). A regional locale negotiates down to an available catalog (pt-BR → pt), then falls back to defaultLocale, then to the key itself.
Format
Each translator carries Intl-based formatters in the same locale:
const l = i18n.in('en')
l.n(1234.5) // '1,234.5'
l.currency(9.9, 'USD') // '$9.90'
l.date(new Date(), { dateStyle: 'long' })
l.relativeTime(-1, 'day') // '1 day ago'
l.list(['a', 'b', 'c']) // 'a, b, and c'In a request
Inside a handler, call i18n.t / the formatters directly — they use the context locale with no per-call plumbing:
import { z } from 'zod'
import { route } from '@basaltkit/fastify'
import { i18n } from './app.js' // the module-scoped instance resolved above
export const summary = route({
method: 'GET',
url: '/summary',
query: z.object({ count: z.coerce.number() }),
handler({ query }) {
return {
title: i18n.t('greeting', { name: 'Ada' }), // context locale
line: i18n.t('notes', { count: query.count }), // '1 note' / '3 notes'
when: i18n.relativeTime(-1, 'day'), // '1 day ago' / 'há 1 dia'
}
},
})Pair it with @basaltkit/mailer / @basaltkit/notifications to render outbound content in the recipient's locale.