Guide/guides

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>/*.json during boot.
  • Each request's locale is detected from the ?locale= query parameter, a locale cookie, or the Accept-Language header (in that order), restricted to supported.
  • Controllers translate with this.t() / this.tc(), and Inertia pages with useTranslation().
  • 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

  1. Keep locales in parity: run guren check --i18n in CI so a key added to one locale can't silently ship untranslated.
  2. Never call setLocale() per request: rely on the detected locale and the request-scoped helpers.
  3. 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