Internationalization (i18n)
Translate your application into multiple languages with JSON locale files, automatic Accept-Language negotiation, and a clean interpolation and pluralization API.
Overview
@tekir/i18n provides the I18n class, which loads flat JSON translation files from disk, exposes a t() method for string lookup and interpolation, a plural() helper for count-based variants, and a middleware()factory that automatically detects the visitor's preferred language from the Accept-Language request header.
Installation
bun add @tekir/i18nTranslation Files
Create one JSON file per locale inside resources/lang/. The filename (without extension) becomes the locale identifier. Keys are arbitrary dot-separated strings; values are the translated strings. Use {{ placeholder }} syntax for dynamic values.
{
"welcome": "Welcome, {{ name }}!",
"goodbye": "Goodbye!",
"items_zero": "No items",
"items_one": "{{ count }} item",
"items_other": "{{ count }} items",
"errors.required": "This field is required.",
"errors.email": "Enter a valid email address."
}{
"welcome": "Hoş geldin, {{ name }}!",
"goodbye": "Güle güle!",
"items_zero": "Hiç öğe yok",
"items_one": "{{ count }} öğe",
"items_other": "{{ count }} öğe",
"errors.required": "Bu alan zorunludur.",
"errors.email": "Geçerli bir e-posta girin."
}Setup
Create config/i18n.ts and register I18nProvider in your kernel. The provider reads all .json files from localesDir automatically.
import type { I18nConfig } from '@tekir/i18n'
export default {
defaultLocale: 'en',
fallbackLocale: 'en',
localesDir: 'resources/lang',
} satisfies I18nConfigimport type { TekirApp } from '@tekir/core'
import { I18nProvider } from '@tekir/i18n'
export default function({ app }: TekirApp) {
app.registerAll([I18nProvider])
}import { service } from '@tekir/core'
import type { I18n } from '@tekir/i18n'
export const i18n = service<I18n>('i18n')Translating Strings
The t(key, params?, locale?) method looks up a translation key. When no explicit locale is provided it uses the current default locale. If the key is missing from both the target locale and the fallback locale, the key itself is returned unchanged, making missing translations visible without crashing.
// Translate a key using the default locale
i18n.t('welcome') // 'Welcome, !' (no params)
i18n.t('goodbye') // 'Goodbye!'
// Translate a key for an explicit locale
i18n.t('goodbye', {}, 'tr') // 'Güle güle!'
// Falls back to the fallback locale when a key is missing
i18n.t('missing.key') // returns 'missing.key' when not found anywhereInterpolation
Any {{ placeholder }} token in a translation value is replaced by the matching property from the params object. The replacement is performed with a global regex, so the same placeholder can appear multiple times.
// Curly-brace placeholders are replaced with param values
i18n.t('welcome', { name: 'Alice' }) // 'Welcome, Alice!'
i18n.t('welcome', { name: 'Ali' }, 'tr') // 'Hoş geldin, Ali!'
// Whitespace inside {{ }} is trimmed, so {{ name }} and {{name}} both workPluralization
plural(key, count, params?, locale?) selects between three key variants based on count: _zero when count is 0, _one when count is 1, and _other for everything else. The count is automatically injected into the params object so you can display it in the string.
{
"items_zero": "No items",
"items_one": "{{ count }} item",
"items_other": "{{ count }} items"
}// plural(key, count, params?, locale?)
// Looks up: key + '_zero' | '_one' | '_other'
// Automatically injects { count } into params
i18n.plural('items', 0) // 'No items'
i18n.plural('items', 1) // '1 item'
i18n.plural('items', 5) // '5 items'
i18n.plural('items', 3, {}, 'tr') // '3 öğe'
// You can combine count with other params
// Translation: "{{ name }} has {{ count }} item(s)"
i18n.plural('user_items', 2, { name: 'Alice' }) // 'Alice has 2 item(s)'Locale Management
The I18n instance exposes several properties and methods for inspecting and changing the active locale at runtime.
// Read the current default locale
i18n.locale // 'en'
// Change the default locale at runtime
i18n.setLocale('tr')
i18n.locale // 'tr'
// Get all translations for a locale (plain object)
i18n.getLocale('en') // { welcome: 'Welcome, {{ name }}!', ... }
// List all locales that have been loaded
i18n.availableLocales // ['en', 'tr']- locale (getter): returns the current default locale string.
- setLocale(locale): Changes the default locale used by calls to
t()andplural()that omit thelocaleargument. - getLocale(locale): Returns the full translations object for a given locale, or an empty object if that locale has not been loaded.
- availableLocales (getter): array of locale identifiers that have been loaded, derived from the JSON filenames.
Accept-Language Middleware
Call i18n.middleware() to get a middleware function that reads the incoming Accept-Language header, resolves it to a loaded locale, and attaches the following properties to the request context for the duration of that request:
- locale: The resolved locale string (e.g.
"tr"). - t(key, params?): A convenience translate function pre-bound to the request locale. Equivalent to
i18n.t(key, params, locale). - i18n: The full
I18ninstance for advanced usage.
// Register the i18n middleware to detect locale from Accept-Language header
// start/kernel.ts
import { I18nProvider } from '@tekir/i18n'
export default function({ app }) {
app.registerAll([I18nProvider])
}import type { HttpContext } from '@tekir/core'
export async function greet({ t, auth, response }: HttpContext) {
// t() : pre-bound translate helper for the request locale
// locale : the resolved locale string (e.g. 'tr')
// i18n : the full I18n instance
const message = t('welcome', { name: auth.user.name })
return response.ok({ message })
}The locale resolution logic works as follows:
// The middleware reads the Accept-Language header:
// Accept-Language: tr-TR,tr;q=0.9,en;q=0.8
//
// It takes the first preferred language, strips the region tag ('tr-TR' → 'tr'),
// and checks whether that locale has been loaded.
// If it has, locale = 'tr' and t() uses 'tr'.
// If not, locale = defaultLocale and t() uses the default.Loading Translations Programmatically
Use load(locale, translations) to add or merge translations at runtime. This is useful when translation data comes from a database, a remote source, or test fixtures. Existing keys are preserved unless overwritten.
// Load or merge translations at runtime (useful for testing or dynamic sources)
i18n.load('fr', {
'welcome': 'Bienvenue, {{ name }} !',
'goodbye': 'Au revoir !'
})
i18n.t('welcome', { name: 'Marie' }, 'fr') // 'Bienvenue, Marie !'
// load() merges into existing translations: it does not replace them
i18n.load('en', { 'new.key': 'New value' })