Using i18n

Render a string

app.get("/board", (c) => c.html(c.var.t.t("app/board.title")))

c.var.t is on every request whether or not you composed the capability — core seeds it with each composed capability’s English. Composing i18n replaces it with a translator that negotiated the reader’s locale.

Add a language

Two steps — and serving a second language walks them with the rest of the setup around them:

i18n({
  supportedLocales: ["en", "es"],
  messages: {
    es: { "app/board.title": "Tablero" },
  },
})

Ship the least specific tag that is true — es, not es-ES. Every option here is in the reference, and the browser half negotiates on its own chain. The catalog locale falls back and the formatting locale does not, so a regional reader already gets regional dates from a plain es catalog.

The default locale must be one of the supported ones; the config refuses otherwise.

Override one kit sentence

The same messages field. One entry, one key — overriding one sentence is the whole procedure:

messages: {
  en: { "auth/sign_in.title": "Welcome back to Board." },
}

Every key you did not mention keeps flowing from the package, because the merge is per key. Passing a whole locale object instead is the fork this design exists to avoid.

Declare your own keys

Under your own capability’s name, contributed like any other capability’s messages. app/board.title is yours. A key under auth/ is refused with the domain named.

Plurals

t.plural("app/board.entries", count)

It looks up <key>.<category> and falls back to <key>.other. Write the categories the language actually has — English needs one and other; other languages need more, and the fallback covers a category you have not written yet.

Formatting

formatNumber, formatCurrency, formatDate, formatList and formatRelativeTime all run against formattingLocale, which is the reader’s full tag rather than the catalog’s.

So es-AR gets Argentine formatting from an es catalog, with nobody writing an es-AR anything.

Render an error a client can translate

t.maybe(payload.code, payload.params) ?? payload.message;

maybe, not t — t is total and never misses, so it can never fall through to the English the server sent. See the translator interface.

Set lang and dir on the document

Read c.var.locale rather than t — it carries the tags themselves and the direction:

const { formattingLocale, direction } = c.var.locale ?? {};

null means nothing was negotiated, which is a different fact from the default was chosen, and it is what a project with no i18n composed looks like.

Localize your own copy before you enqueue an email

The kit translates the half it wrote. A template whose words arrive as payload is only as localized as your payload.

An operational notice enqueued at locale es takes its severity label from the Spanish catalog and your summary straight from your payload — delivering a Spanish label in front of an English sentence. That is correct behavior, and it is a surprise unless somebody says it out loud.

So look your summary up first, under your own domain, and hand the rendered string to the enqueue along with the same locale. A catalog cannot translate a sentence it has never seen.

In the browser

useNegotiatedLocale walks the browser chain and useTranslator gives you the same object. Pass persist so a signed-in reader’s choice reaches their account rather than only this device.

Screens are fetched one locale at a time as their own chunk, so adding a language does not grow the bundle every reader downloads.

ESC