Internationalization (i18n) Guide
Guren ships internationalization end to end: file-based translation catalogs, per-request locale detection, translation helpers in controllers and React pages, pluralization rules for 15+ languages, typed translation keys, and a consistency checker for your catalogs.
Internationalization (i18n) Guide
Guren ships internationalization end to end: file-based translation catalogs, per-request locale detection, translation helpers in controllers and React pages, pluralization rules for 15+ languages, typed translation keys, and a consistency checker for your catalogs.
Quick Start
Enable i18n by passing the i18n option to createApp and putting your catalogs under lang/<locale>/:
// src/app.ts
import { createApp } from '@guren/core'
import { registerWebRoutes } from '../routes/web.js'
const app = createApp({
routes: registerWebRoutes,
i18n: {
supported: ['en', 'ja'], // first entry is the default/fallback locale
},
})
// lang/en/messages.json
{
"hello": "Hello",
"welcome": "Welcome, :name!",
"items": "One item|:count items"
}
// lang/ja/messages.json
{
"hello": "こんにちは",
"welcome": "ようこそ、:nameさん!",
"items": ":count個"
}
That single option wires everything:
- Every supported locale is loaded from
lang/<locale>/*.jsonduring boot. - Each request's locale is detected from the
?locale=query parameter, alocalecookie, or theAccept-Languageheader (in that order), restricted tosupported. - Controllers translate with
this.t()/this.tc(), and Inertia pages withuseTranslation(). - Inertia responses carry the resolved locale into
<html lang>and share the active catalogs with the client.
Translate in a controller:
import { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'
export default class HomeController extends Controller {
async index() {
return this.inertia(pages.Home, {
message: this.t('messages.welcome', { name: 'Guren' }),
})
}
}
And in a React page or component:
import { useTranslation } from '@guren/inertia-client'
export default function Nav() {
const { t, tc, locale } = useTranslation()
return (
<nav>
<span>{t('messages.hello')}</span>
<span>{tc('messages.items', 5)}</span> {/* '5 items' / '5個' */}
</nav>
)
}
A visitor whose locale resolves to ja sees the Japanese catalog everywhere — server-rendered text, client-side text, and <html lang="ja"> — with no further wiring.
Translation Files
Catalogs live in one directory per locale; each JSON file becomes a namespace, and nested objects become dot-notation keys:
lang/
├── en/
│ ├── messages.json # keys: messages.*
│ ├── validation.json # keys: validation.*
│ └── errors.json # keys: errors.*
└── ja/
├── messages.json
├── validation.json
└── errors.json
// lang/en/messages.json
{
"hello": "Hello",
"user": {
"profile": "Profile",
"settings": "Settings"
}
}
this.t('messages.hello') // 'Hello'
this.t('messages.user.profile') // 'Profile'
Dots are always path separators: a property name or file name containing a literal dot ("a.b": "...", nav.admin.json) can never be resolved and is reported by guren check --i18n. Nest objects instead.
A key missing from the active locale falls back to the fallback locale (the first entry in supported, or the fallback option); a key missing everywhere is returned as-is.
Interpolation
Two placeholder styles are supported, and both insert values literally — replacement values are never interpreted as patterns:
{
"greeting": "Hello, :name!",
"braced": "Hello, {name}!"
}
this.t('messages.greeting', { name: 'World' }) // 'Hello, World!'
this.t('messages.braced', { name: 'World' }) // 'Hello, World!'
Pluralization
Separate plural forms with | and translate with tc(); the form is chosen by the active locale's rule and :count is available as a replacement automatically:
{
"apple": "apple|apples",
"item": "One item|:count items"
}
this.tc('messages.apple', 1) // 'apple'
this.tc('messages.apple', 5) // 'apples'
this.tc('messages.item', 10) // '10 items'
Supported Languages
| Language | Forms | Rule |
|---|---|---|
| English, German, Spanish, Italian, Portuguese, Dutch | 2 | 1 = singular, else plural |
| French, Brazilian Portuguese | 2 | 0-1 = singular, else plural |
| Japanese, Chinese, Korean, Vietnamese, Thai | 1 | No plural forms |
| Russian, Ukrainian | 3 | one / few / many by mod-10 and mod-100 (21 is "one", 22-24 "few") |
| Polish, Czech, Slovak | 3 | Complex rules |
| Arabic | 6 | Most complex pluralization |
Languages with more than two forms list them in rule order:
// lang/ru/messages.json — one | few | many
{
"apple": "яблоко|яблока|яблок"
}
this.tc('messages.apple', 1) // 'яблоко' (one)
this.tc('messages.apple', 2) // 'яблока' (few)
this.tc('messages.apple', 5) // 'яблок' (many)
this.tc('messages.apple', 21) // 'яблоко' (one — special case)
Locale Detection
createApp({ i18n }) mounts locale detection automatically. Tune it with the detect option:
createApp({
i18n: {
supported: ['en', 'ja'],
fallback: 'en', // defaults to the first supported locale
detect: {
sources: ['cookie', 'header'], // drop query-parameter detection
queryParam: 'locale', // defaults shown
cookieName: 'locale',
},
},
})
Accept-Language matching understands region subtags (ja-JP matches ja) and q-values. Detection only ever resolves to a locale in supported.
Pass detect: false to mount detectLocaleMiddleware yourself — or to substitute your own middleware that sets the locale context variable (for example from a signed-in user's saved preference). Everything downstream — this.t(), _i18n, <html lang> — follows the context variable, even when other middleware overrides it after detection.
Inertia responses use the resolved locale for the root <html lang> attribute. A per-response lang option always wins:
return this.inertia(pages.posts.Show, { post }, { lang: 'ja' })
Controllers
Every controller has translation helpers scoped to the current request's locale:
export default class PostController extends Controller {
async index() {
this.locale // 'ja'
this.t('messages.hello') // 'こんにちは'
this.t('messages.welcome', { name: 'ゲスト' }) // interpolation
this.tc('messages.items', 3) // pluralization
// ...
}
}
React Pages
The server shares the resolved locale and its catalogs (active locale plus fallback) with Inertia pages as the _i18n prop, and useTranslation() consumes it:
import { useTranslation } from '@guren/inertia-client'
const { t, tc, locale } = useTranslation()
Client-side translation matches the server's default semantics — interpolation, plural forms, fallback lookup — so a key renders identically whether translated in a controller or in the browser. (Server-only Translator customizations such as custom pluralization rules or an onMissingKey handler are functions and cannot travel in the serialized prop, so they apply on the server only.) Server-side rendering needs no extra wiring; the shared prop covers both passes.
Code that already holds the Inertia page object — outside the hook — can build a translator directly from the prop:
import { createTranslator, type I18nPageProps } from '@guren/inertia-client'
import type { Page } from '@inertiajs/core'
export function translatorFor(page: Page) {
return createTranslator(page.props._i18n as I18nPageProps)
}
Set share: false on the i18n option to keep catalogs out of page props (e.g. an app that only translates server-side).
Typed Translation Keys
guren codegen reads lang/ and generates .guren/translations.gen.ts, registering every key as a TypeScript union. From then on this.t(), this.tc(), and useTranslation()'s t/tc autocomplete keys and reject unknown ones at compile time:
this.t('messages.welcome') // ✓ autocompleted
this.t('messages.welcmoe') // ✗ compile error
bunx guren codegen
The dev server regenerates automatically when translation JSON files under lang/ change. Apps without a lang/ directory keep plain string keys.
Checking Catalogs
guren check validates translation catalogs (and --i18n runs just these checks, exiting non-zero on failures — useful in CI):
bunx guren check --i18n
It reports:
- Invalid JSON — an unparseable catalog file is skipped by the loader (with only a console warning), so all of its keys fall back at runtime.
- Missing keys — a key present in one locale but missing from another renders in the fallback language for that locale's users (or echoes the key when the fallback lacks it too).
- Placeholder mismatches —
:name/{name}placeholders that differ between locales for the same key usually mean a lost variable in translation. Reported as a warning (a locale can legitimately drop a placeholder), so it does not affect the exit code. - Unreachable keys — property or file names containing literal dots, which the runtime cannot resolve.
Serverless and Bundled Catalogs
lang/ is read from the filesystem, which serverless targets (see the Serverless guide) may not ship. Bundle the catalogs instead with a MemoryLoader:
import { createApp, MemoryLoader } from '@guren/core'
import en from '../lang/en/messages.json'
import ja from '../lang/ja/messages.json'
const app = createApp({
i18n: {
supported: ['en', 'ja'],
loader: new MemoryLoader({
en: { messages: en },
ja: { messages: ja },
}),
},
})
The loader option accepts anything implementing the TranslationLoader interface, so catalogs can also come from a database or a remote service. Note that codegen and check --i18n read the conventional lang/ directory — keeping the JSON files there (and bundling them via imports as above) preserves typed keys and catalog checks.
Testing
Translation behavior is easiest to test through the real app — wrap it with TestApp (see the Testing guide):
import { describe, test, beforeAll } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'
describe('i18n', () => {
let http: TestApp
beforeAll(async () => {
http = await TestApp.fromApp(app)
})
test('serves Japanese for ?locale=ja', async () => {
const response = await http.get('/?locale=ja')
response.assertOk()
await response.assertBodyContains('こんにちは')
})
})
For unit tests of catalogs themselves, build a manager directly:
import { createI18n } from '@guren/core'
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: {
en: { messages: { items: 'One item|:count items' } },
},
})
expect(i18n.tc('messages.items', 5)).toBe('5 items')
Advanced: Direct Manager Usage
createApp({ i18n }) manages an I18nManager for you (available from the container as i18n). For scripts, custom middleware, or non-HTTP contexts you can drive one directly:
import { JsonLoader, createI18n } from '@guren/core'
const i18n = createI18n({
locale: 'en',
fallbackLocale: 'en',
loader: new JsonLoader('./lang', { cache: true }),
})
// The manager loads nothing on construction — load every locale you use.
await i18n.loadLocales(['en', 'ja'])
i18n.t('messages.hello')
i18n.tc('messages.items', 5)
i18n.has('messages.hello', 'ja')
i18n.getAvailableLocales()
// A translator pinned to one locale — safe to use concurrently,
// unlike calling setLocale() on a shared manager.
const ja = i18n.forLocale('ja')
ja.t('messages.hello') // 'こんにちは'
Custom pluralization rules:
const translator = i18n.getTranslator()
translator.setPluralizationRule('custom', (count) => {
if (count === 0) return 0
if (count === 1) return 1
return 2
})
A setI18n()/t() global registry also exists for simple scripts. Avoid it in request handlers: a shared manager's setLocale() is process-wide state, so concurrent requests would override each other's locale. In apps wired through createApp({ i18n }), the request-scoped helpers (this.t(), useTranslation()) are safe — they resolve through the request and the app's container, never through shared mutable locale state.
Best Practices
- Keep locales in parity: run
guren check --i18nin CI so a key added to one locale can't silently ship untranslated. - Never call
setLocale()per request: rely on the detected locale and the request-scoped helpers. - Use namespaced keys: organize translations by feature (
user.profile.title), one file per namespace.
Next Steps
- Testing — assert translated responses through
TestApp - Serverless — deploy targets without a filesystem