Internationalization (i18n)
Bring your own
KickJS ships no i18n package. This guide builds it from parts the framework already has, a context contributor and the per-request store, in about 60 lines. Translations come from JSON files, or from i18next when you want its features.
Every request gets a locale, picked once. A plain t('key') then translates in that locale wherever it's called: in a handler, a service or a repository, without passing ctx around. Outside a request (a queue job, an email), runWithLocale() sets the locale.
Pick the locale
The order matches what users expect: an explicit choice beats the browser's preference.
?lang=frin the URL- a
langcookie (the user picked a language) - the signed-in user's saved preference
Accept-Language, by its q-values;fr-CAfalls back tofr- the default
// src/i18n/locale.ts
import { defineHttpContextDecorator } from '@forinda/kickjs'
export const LOCALES = ['en', 'fr', 'sw'] as const
export type Locale = (typeof LOCALES)[number]
export const DEFAULT_LOCALE: Locale = 'en'
declare module '@forinda/kickjs' {
interface ContextMeta {
locale: Locale
}
}
/** A supported locale for a language tag: exact, else its language (`fr-CA` → `fr`). */
export function matchLocale(tag: unknown): Locale | undefined {
if (typeof tag !== 'string' || !tag) return undefined
const lower = tag.toLowerCase()
const language = lower.split('-')[0]
return LOCALES.find((l) => l === lower) ?? LOCALES.find((l) => l === language)
}
/** The best supported locale in an Accept-Language header, by its q-values. */
export function negotiate(header: string | undefined): Locale | undefined {
const ranked = (header ?? '')
.split(',')
.map((part) => {
const [tag = '', ...params] = part.trim().split(';')
const q = params.map((p) => p.trim()).find((p) => p.startsWith('q='))
return { tag: tag.trim(), q: q ? Number(q.slice(2)) : 1 }
})
.filter((entry) => entry.tag && entry.q > 0)
.toSorted((a, b) => b.q - a.q)
for (const { tag } of ranked) {
const locale = matchLocale(tag)
if (locale) return locale
}
return undefined
}
export const LoadLocale = defineHttpContextDecorator({
key: 'locale',
resolve: (ctx) => {
const cookie = /(?:^|;\s*)lang=([^;]+)/.exec(String(ctx.headers.cookie ?? ''))?.[1]
const locale =
matchLocale(ctx.query.lang) ??
matchLocale(cookie) ??
matchLocale(ctx.user?.locale) ??
negotiate(ctx.headers['accept-language'] as string | undefined) ??
DEFAULT_LOCALE
ctx.setHeader('Content-Language', locale)
return locale
},
})Register it once for every route:
// src/index.ts
bootstrap({
modules,
contributors: [LoadLocale.registration],
})ctx.get('locale') is now typed in every handler. Register it on a module (contributors()) or a single route (@LoadLocale) instead if only part of the app is translated.
Translate
One JSON file per locale. Plain keys hold a string; a key with plural forms holds one string per plural category (one, few, many, other, …):
// src/i18n/messages/en.json
{
"greeting": "Hello, {name}",
"cart.items": { "one": "{count} item", "other": "{count} items" }
}// src/i18n/t.ts
import { AsyncLocalStorage } from 'node:async_hooks'
import { getRequestValue } from '@forinda/kickjs'
import { DEFAULT_LOCALE, type Locale } from './locale'
import en from './messages/en.json'
import fr from './messages/fr.json'
import sw from './messages/sw.json'
type Messages = typeof en
export type MessageKey = keyof Messages
// `satisfies`: a locale file missing a key fails the typecheck.
const catalogs = { en, fr, sw } satisfies Record<Locale, Messages>
const override = new AsyncLocalStorage<Locale>()
/** The locale in force: `runWithLocale`'s, else the request's, else the default. */
export function currentLocale(): Locale {
return override.getStore() ?? getRequestValue('locale') ?? DEFAULT_LOCALE
}
/** Run `fn` in `locale` — a queue job, an email, anything outside a request. */
export function runWithLocale<T>(locale: Locale, fn: () => T): T {
return override.run(locale, fn)
}
export function t(key: MessageKey, vars: Record<string, string | number> = {}): string {
const locale = currentLocale()
const entry: string | Record<string, string> = catalogs[locale][key]
const text =
typeof entry === 'string'
? entry
: (entry[new Intl.PluralRules(locale).select(Number(vars.count ?? 0))] ?? entry.other!)
return text.replace(/\{(\w+)\}/g, (match, name: string) => String(vars[name] ?? match))
}During a request, the locale comes from KickJS's own per-request store (getRequestValue), where the contributor put it. The separate AsyncLocalStorage serves runWithLocale() alone: it overrides the locale and nothing else. Running a new request store instead would hide the current request's other values and request-scoped services, or invent a request outside one.
t() reads the locale the request picked, so it works anywhere the request's code runs:
@Service()
export class CartService {
summary(count: number) {
return t('cart.items', { count }) // "2 articles" for a French request
}
}
@Controller()
export class ShopController {
@Get('/')
home(ctx: RequestContext) {
ctx.json({ title: t('greeting', { name: ctx.user?.name ?? 'guest' }) })
}
}Keys are typed from the default locale's file, so t('cart.itmes') fails the typecheck. JSON imports need "resolveJsonModule": true in tsconfig.json.
Format numbers, currencies and dates with the platform's Intl and the same locale:
new Intl.NumberFormat(currentLocale(), { style: 'currency', currency: 'EUR' }).format(12.5)
new Intl.DateTimeFormat(currentLocale(), { dateStyle: 'long' }).format(order.createdAt)With i18next
For nested keys, context, interpolation formats and translation-management tooling, swap the body of t() for i18next. Picking the locale and runWithLocale stay as they are:
// src/i18n/t.ts
import i18next from 'i18next'
await i18next.init({
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...LOCALES],
resources: { en: { translation: en }, fr: { translation: fr }, sw: { translation: sw } },
})
export function t(key: string, vars: Record<string, unknown> = {}): string {
return i18next.getFixedT(currentLocale())(key, vars)
}i18next writes plural forms as separate keys (cart.items_one, cart.items_other) and interpolates by default.
Let users choose
A route that stores the choice in the cookie the contributor reads:
@Post('/locale')
setLocale(ctx: RequestContext) {
const locale = matchLocale(ctx.body?.locale)
if (!locale) return ctx.problem.badRequest({ detail: `Supported: ${LOCALES.join(', ')}` })
ctx.setHeader('Set-Cookie', `lang=${locale}; Path=/; Max-Age=31536000; SameSite=Lax`)
ctx.noContent()
}Outside a request
A queue job or a scheduled email has no request, so t() falls back to the default. Put the locale in the job's data and run the work in it:
// dispatching, during the request (`this.jobs` is the injected JOB_DISPATCHER)
await this.jobs.dispatch('emails', 'receipt', { orderId, locale: ctx.get('locale') })
// the job
@Process('receipt')
async receipt(job: { data: { orderId: string; locale: Locale } }) {
await runWithLocale(job.data.locale, () => this.mailer.sendReceipt(job.data.orderId))
}Validation errors
t() reads the current request's locale, so a schema library's global error map translates per request. With Zod 4:
// src/i18n/zod.ts — import it once at startup
import { z } from 'zod'
import { t } from './t'
z.config({
customError: (issue) => {
if (issue.code === 'too_small') return t('validation.tooSmall', { min: Number(issue.minimum) })
return undefined // Zod's own message
},
})Valibot takes a lang per parse; pass currentLocale(). Yup's setLocale() accepts functions as messages, and they run at validation time, so they can call t().
Error responses you send yourself translate the same way: ctx.problem.notFound({ title: t('errors.orderNotFound') }).
On the edge
t() and runWithLocale use AsyncLocalStorage, like the framework's own per-request store. On Cloudflare Workers that needs the nodejs_compat flag; see Edge deployment.