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. Both insert values literally, and 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, for use 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 deploy targets do not ship. The deploy builds (guren cloudflare:build, guren lambda:build and the Vercel build) read lang/<locale>/*.json at build time and bake the catalogs into their output, so an app using the default directory translates after deploy with no change. A file that is not valid JSON is left out, and the build warns. Changing a translation takes a rebuild and a new deploy.
The build reads lang/ only. An app that sets path, or runs somewhere without a Guren deploy build (see the Serverless guide), bundles the catalogs itself with a MemoryLoader, which wins over anything a build baked in:
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
})
t() resolves the manager from the default application, which is enough for a simple script. 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