i18n
Acme's first customers all read English. Then a team in Berlin signs up, and every button, empty state and order total needs to read naturally in German too. @kitr/i18n moves your interface text into one file per language, picks the visitor's language automatically, and makes a misspelled key a type error.
It is react-i18next and i18next, re-exported unchanged, with a provider and sensible defaults: the fallback language is your default locale, there is no HTML escaping (React already escapes) and no Suspense. It adds matchLocale() for Accept-Language headers and a TranslationKeys type for typed key props.
The provider runs in the browser, inside React. On the server you can still call createI18n() or matchLocale() directly, to translate an email or pick a page's language.
The examples translate Acme's order screens at app.acme.com into English and German.
Install
kitr add i18nFirst time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.
Everything kitr add changes
What kitr add does:
- Adds
@kitr/i18n,i18nextandreact-i18nexttodependenciesand installs them. Pass--no-installto skip the install. - Adds
() => import('@kitr/i18n/provider')toprovidersand'@kitr/i18n'topackagesinkitrrc.ts. The provider bindsi18nin the container and wraps your app in react-i18next'sI18nextProvider. - Writes
app/locales/en.tswith acommonnamespace holdinghelloandsave, unless the file exists. - Writes
config/i18n.ts, unless it exists. See Configure. - Adds a
## kitr: i18nsection toAGENTS.md: strings live inapp/locales/<lang>.ts, components callconst { t } = useTranslation()from@kitr/i18n, and keys are typed from the English bundle.
The site kit includes i18n with English and German bundles, a language switch, and the visitor's choice in a cookie so server-rendered pages arrive in the right language.
kitr remove i18n takes the provider and the package out of kitrrc.ts, deletes config/i18n.ts, removes the three dependencies and the AGENTS.md section, then lists files that still import @kitr/i18n. It leaves app/locales/ in place.
Configure
kitr add i18n writes this file:
// config/i18n.ts
import { defineConfig } from '@kitr/i18n'
import en from '#app/locales/en'
export default defineConfig({
resources: { en },
defaultLocale: 'en',
defaultNS: 'common',
})
| Option | Default | Description |
|---|---|---|
resources |
required | Every locale's strings, keyed by locale then namespace: { en: { common: { ... } } }. |
defaultLocale |
required | The language used when nothing better matches, and the fallback for any key missing in another language. |
locales |
the keys of resources |
The languages the app supports. A language outside this list is never chosen, even when you call changeLanguage() with it. |
detect |
true |
In the browser, choose the language from the saved choice under storageKey, then from the browser's languages. false always starts in defaultLocale. |
storageKey |
'locale' |
The localStorage key detection reads for a saved choice. |
defaultNS |
'common' when a bundle has it, else the first namespace in resources |
The namespace t('save') looks in when the key names none. |
Any other i18next option passes straight through, and wins over kitr's defaults:
| i18next option | kitr's default | Why |
|---|---|---|
fallbackLng |
defaultLocale |
A German page with a missing key shows the English string, not the key. |
supportedLngs |
locales |
Only languages you ship are chosen. |
interpolation.escapeValue |
false |
React escapes text already; escaping twice shows <. |
react.useSuspense |
false |
Bundles are in memory, so there is nothing to wait for. |
Translate your first string
The generated app/locales/en.ts holds one namespace, common:
// app/locales/en.ts
export default {
common: {
hello: 'Hello, {{name}}',
save: 'Save',
},
} as const
In a component, useTranslation() from @kitr/i18n returns t, which looks a key up in the current language:
// app/components/orders/welcome.tsx
import { useTranslation } from '@kitr/i18n'
export function Welcome({ name }: { name: string }) {
const { t } = useTranslation()
return (
<section>
<h2>{t('hello', { name })}</h2>
<button type="submit">{t('save')}</button>
</section>
)
}
<Welcome name="Ada" /> renders "Hello, Ada" and a "Save" button. as const keeps the exact strings, which typed keys need.
Organize strings into namespaces
Each top-level key in a bundle is a namespace. Keep common for words used everywhere and give each feature its own:
// app/locales/en.ts
export default {
common: {
hello: 'Hello, {{name}}',
save: 'Save',
cancel: 'Cancel',
},
orders: {
title: 'Orders',
empty: 'No orders yet',
status: { open: 'Open', paid: 'Paid', refunded: 'Refunded' },
},
} as const
Pass a namespace to useTranslation to make it the default for that component. Reach another namespace with a namespace: prefix, and nested keys with dots:
// app/components/orders/orders-header.tsx
import { useTranslation } from '@kitr/i18n'
export function OrdersHeader() {
const { t } = useTranslation('orders')
return (
<header>
<h1>{t('title')}</h1>
<span>{t('status.open')}</span>
<button type="button">{t('common:cancel')}</button>
</header>
)
}
For one branch, useTranslation('orders', { keyPrefix: 'status' }) lets you write t('paid') for orders.status.paid.
Insert values
Put a placeholder in double braces and pass the value under the same name:
// app/components/orders/assigned-to.tsx
import { useTranslation } from '@kitr/i18n'
export function AssignedTo({ order }: { order: { id: string; owner: string } }) {
const { t } = useTranslation('orders')
// orders.assigned: 'Order {{id}} is assigned to {{owner}}'
return <p>{t('assigned', { id: order.id, owner: order.owner })}</p>
// => Order 1001 is assigned to Grace
}
Count things
Give a key one variant per plural form and pass count. i18next picks the form for the current language:
// app/locales/en.ts
export default {
orders: {
count_zero: 'No orders',
count_one: '{{count}} order',
count_other: '{{count}} orders',
},
} as const
// app/components/orders/order-count.tsx
import { useTranslation } from '@kitr/i18n'
export function OrderCount({ count }: { count: number }) {
const { t } = useTranslation('orders')
return <p>{t('count', { count })}</p>
}
// count 0 => No orders
// count 1 => 1 order
// count 3 => 3 orders
Ask for count, never count_one. _zero is optional. Other languages need other forms, such as Polish _few and _many; see the i18next plural guide.
Format numbers, money and dates
Name a formatter after the value inside the braces. It uses Intl with the current language, so one key prints €1,234.50 in English and 1.234,50 € in German:
// app/locales/en.ts
export default {
orders: {
total: 'Total: {{amount, currency(EUR)}}',
placed: 'Placed on {{date, datetime}}',
items: '{{count, number}} items',
},
} as const
// app/components/orders/order-summary.tsx
import { useTranslation } from '@kitr/i18n'
export function OrderSummary({ order }: { order: { total: number; placedAt: Date } }) {
const { t } = useTranslation('orders')
return (
<dl>
<dd>{t('total', { amount: order.total })}</dd>
<dd>{t('placed', { date: order.placedAt })}</dd>
</dl>
)
}
// en, total 1234.5 => Total: €1,234.50
// de, total 1234.5 => Summe: 1.234,50 €
// de, placedAt 2026-03-01 => Bestellt am 1.3.2026
The formatters are number, currency, datetime, relativetime and list, each taking Intl options in parentheses; see i18next formatting. Pass raw numbers and Date objects to t; don't format them first.
Put markup inside a string
A sentence with a link or bold words can't be split without breaking word order in other languages. Keep it as one string with named tags and render it with Trans:
// app/locales/en.ts
export default {
common: {
welcome: 'Welcome back, <bold>{{name}}</bold>. <link>See your orders</link>.',
},
} as const
// app/components/orders/welcome-back.tsx
import { Trans } from '@kitr/i18n'
export function WelcomeBack({ name }: { name: string }) {
return (
<p>
<Trans
i18nKey="common:welcome"
values={{ name }}
components={{ bold: <strong />, link: <a href="https://app.acme.com/orders" /> }}
/>
</p>
)
}
The German bundle can move the tags anywhere in its sentence. Trans in react-i18next's docs covers the other forms.
Add a language
Copy the English bundle, translate it, and list it in the config. Typing the copy against the English one makes a missing key fail the type check:
// app/locales/en.ts
const en = {
common: { hello: 'Hello, {{name}}', save: 'Save' },
orders: { title: 'Orders', empty: 'No orders yet' },
} as const
export default en
/** The shape every other locale must match: same keys, any strings. */
export type Bundle<T> = { [K in keyof T]: T[K] extends string ? string : Bundle<T[K]> }
// app/locales/de.ts
import type { Bundle } from './en'
import type en from './en'
const de = {
common: { hello: 'Hallo, {{name}}', save: 'Speichern' },
orders: { title: 'Bestellungen', empty: 'Noch keine Bestellungen' },
} as const satisfies Bundle<typeof en>
export default de
// config/i18n.ts
import { defineConfig } from '@kitr/i18n'
import de from '#app/locales/de'
import en from '#app/locales/en'
export default defineConfig({
resources: { en, de },
defaultLocale: 'en',
defaultNS: 'common',
})
A key missing from de.ts is a type error, and at run time a German page shows the English string. Plural keys are the exception: a language may need forms English lacks, so loosen Bundle for those namespaces.
Type your keys
Tell i18next the shape of the English bundle once, and t() autocompletes every key and rejects typos at compile time:
// app/i18n.d.ts
import type en from '#app/locales/en'
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common'
resources: typeof en
}
}
// app/components/orders/orders-header.tsx
import { useTranslation } from '@kitr/i18n'
export function OrdersHeader() {
const { t } = useTranslation('orders')
return <h1>{t('titel')}</h1>
// Type error: 'titel' is not a key of the orders namespace
}
When a component takes a key as a prop, type it with TranslationKeys, which flattens a namespace into dotted key paths:
// app/components/orders/status-badge.tsx
import { type TranslationKeys, useTranslation } from '@kitr/i18n'
import type en from '#app/locales/en'
type OrderKey = TranslationKeys<(typeof en)['orders']>
// => 'title' | 'empty' | 'status.open' | 'status.paid' | 'status.refunded'
export function StatusBadge({ label }: { label: OrderKey }) {
const { t } = useTranslation('orders')
return <span className="badge">{t(label)}</span>
}
Switch the language
i18n.changeLanguage() switches every mounted component. Save the choice under storageKey so detection finds it next visit:
// app/components/language-switch.tsx
import { useTranslation } from '@kitr/i18n'
const languages = { en: 'English', de: 'Deutsch' } as const
export function LanguageSwitch() {
const { i18n } = useTranslation()
return (
<select
value={i18n.resolvedLanguage}
onChange={(event) => {
const next = event.target.value
localStorage.setItem('locale', next)
void i18n.changeLanguage(next)
}}
>
{Object.entries(languages).map(([code, label]) => (
<option key={code} value={code}>
{label}
</option>
))}
</select>
)
}
changeLanguage writes neither localStorage nor <html lang>. Keep <html lang> in sync for screen readers by listening once at startup:
// app/client.tsx
import { app } from './app'
app.boot().then(() => {
const i18n = app.container.use('i18n')
document.documentElement.lang = i18n.language
i18n.on('languageChanged', (language) => {
document.documentElement.lang = language
})
// ... then hydrate or render as before
})
A language outside locales is never chosen: changeLanguage('fr') without a French bundle stays on en.
Detect the visitor's language
With detect: true, the provider picks the first language in this order:
app.env.locale, when the app was created with one.- The saved choice in
localStorageunderstorageKey, when it is a supported language. - The browser's
navigator.languages, matched against your languages. defaultLocale.
Matching tries each browser language in order: an exact match wins, then the base language (en-GB matches en), and a base matches a regional bundle (pt matches pt-BR). detectLocale(supported, storageKey) runs steps 2 and 3 alone:
// app/lib/settings/language.ts
import { detectLocale } from '@kitr/i18n'
detectLocale(['en', 'de'])
// navigator.languages ['de-AT', 'en'] and nothing saved => 'de'
// localStorage locale = 'en' => 'en'
// on the server => undefined
Detection runs once at startup. Use detect: false when the server decides the language.
Choose the language on the server
On the server the Accept-Language header carries the visitor's preference. matchLocale() parses it and returns the best supported language:
// server/controllers/locale-controller.ts
import type { HttpContext } from '@kitr/api'
import { matchLocale } from '@kitr/i18n'
export default class LocaleController {
async show({ request }: HttpContext) {
const header = request.header('accept-language') ?? ''
return { locale: matchLocale(header, ['en', 'de']) ?? 'en' }
}
}
With ['en', 'de'] supported:
Accept-Language |
Result |
|---|---|
de-CH,de;q=0.9,en;q=0.8 |
'de' |
en-GB |
'en' |
fr-FR,fr;q=0.9 |
undefined |
'' |
undefined |
It reads languages in header order and does not sort by q; browsers already send them in preference order. It also takes an array such as navigator.languages. A server-rendered app usually stores the choice in a cookie so server and browser agree; the site kit does this with storage's defineCookie.
Translate outside components
The provider binds the instance as i18n in the container. Read it where there are no hooks, such as a toast after a mutation:
// app/lib/orders/notify.ts
import type { KitrApp } from '@kitr/kitr/app'
export function orderSavedMessage(app: KitrApp, orderId: string) {
const i18n = app.container.use('i18n')
return i18n.t('orders:saved', { id: orderId })
// en => Order 1001 saved
}
For an email in the customer's language, create an instance from the same config and take a fixed t per language:
// server/mail/order-confirmation.ts
import { createI18n } from '@kitr/i18n'
import i18nConfig from '#config/i18n'
const i18n = createI18n({ ...i18nConfig, detect: false })
export function confirmationSubject(locale: string, count: number) {
const t = i18n.getFixedT(locale, 'orders')
return t('count', { count })
}
// confirmationSubject('de', 2) => 2 Bestellungen
Load a language on demand
Every bundle in resources ships in the main JavaScript bundle. Load rarely used languages when chosen, and list them in locales up front:
// app/lib/settings/load-language.ts
import type { KitrApp } from '@kitr/kitr/app'
export async function useFrench(app: KitrApp) {
const i18n = app.container.use('i18n')
const { default: fr } = await import('#app/locales/fr')
for (const [namespace, strings] of Object.entries(fr)) {
i18n.addResourceBundle('fr', namespace, strings)
}
await i18n.changeLanguage('fr')
}
// config/i18n.ts
import { defineConfig } from '@kitr/i18n'
import de from '#app/locales/de'
import en from '#app/locales/en'
export default defineConfig({
resources: { en, de },
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
defaultNS: 'common',
})
Without fr in locales, changeLanguage('fr') stays on English. To load every language over HTTP, see i18next backend plugins.
Handle missing keys
A key missing in German shows the English string. A key that exists nowhere shows the key itself, without its namespace:
// app/lib/orders/missing.ts
import type { KitrApp } from '@kitr/kitr/app'
export function demo(app: KitrApp) {
const i18n = app.container.use('i18n')
i18n.t('orders:refundNote')
// => 'refundNote' when no bundle has it
}
Typed keys catch this at compile time. To report missing keys at run time, pass i18next's saveMissing and missingKeyHandler in config/i18n.ts.
Testing
Render translated components with testing's createTestApp and renderWithKitr. Use detect: false so the runner's browser language doesn't decide the result, and env: { locale } to render another language:
// app/components/orders/orders-header.test.tsx
import { screen } from '@testing-library/react'
import { useTranslation } from '@kitr/i18n'
import i18nProvider from '@kitr/i18n/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { describe, expect, it } from 'vitest'
import i18nConfig from '#config/i18n'
function OrdersHeader({ count, total }: { count: number; total: number }) {
const { t } = useTranslation('orders')
return (
<header>
<h1>{t('title')}</h1>
<p>{t('count', { count })}</p>
<p>{t('total', { amount: total })}</p>
</header>
)
}
const boot = (locale?: string) =>
createTestApp({
providers: [i18nProvider],
config: { i18n: { ...i18nConfig, detect: false } },
env: locale ? { locale } : {},
})
describe('OrdersHeader', () => {
it('renders in English by default', async () => {
renderWithKitr(<OrdersHeader count={3} total={1234.5} />, { app: await boot() })
expect(screen.getByRole('heading', { name: 'Orders' })).toBeTruthy()
expect(screen.getByText('3 orders')).toBeTruthy()
expect(screen.getByText('Total: €1,234.50')).toBeTruthy()
})
it('renders in German', async () => {
renderWithKitr(<OrdersHeader count={1} total={1234.5} />, { app: await boot('de') })
expect(screen.getByRole('heading', { name: 'Bestellungen' })).toBeTruthy()
expect(screen.getByText('1 Bestellung')).toBeTruthy()
expect(screen.getByText('Summe: 1.234,50 €')).toBeTruthy()
})
})
To test plain functions that call t, skip React and use createI18n(i18nConfig, 'de').t(...).