Skip to content

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.

  1. ?lang=fr in the URL
  2. a lang cookie (the user picked a language)
  3. the signed-in user's saved preference
  4. Accept-Language, by its q-values; fr-CA falls back to fr
  5. the default
ts
// 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:

ts
// 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, …):

json
// src/i18n/messages/en.json
{
  "greeting": "Hello, {name}",
  "cart.items": { "one": "{count} item", "other": "{count} items" }
}
ts
// 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:

ts
@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:

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

ts
// 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:

ts
@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:

ts
// 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:

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

Released under the MIT License. Built with TypeScript — runs on Express, Fastify, or h3.