Skip to content

i18n

Translations for a Void app’s pages and server messages, on Intl. Messages live in code; the locale is the user’s choice in a cookie, else the browser’s Accept-Language, else the messages’ own language. Numbers, money, dates, relative times and lists come from Intl in that locale. The module imports nothing, so the Worker and the browser share it. examples/crm uses it.

The messages, in a file of your own that the server and the pages both import:

src/messages.ts
import { defineI18n } from "@recranet/flare/i18n"
export const i18n = defineI18n({
locale: "en",
messages: {
"overview.welcome": "Welcome back, {name}.",
"overview.openTasks": "{count, plural, one {# open task} other {# open tasks}}",
},
translations: {
nl: { "overview.welcome": "Welkom terug, {name}.", "overview.openTasks": "{count, plural, one {# open taak} other {# open taken}}" },
},
})
defineI18n
locale The language messages are in: the default, and the fallback for a key a translation lacks
messages Messages by key. Their keys are what t() takes, typed
translations Other locales, each with some or all of the keys
cookie The override cookie’s name. Default locale

The locale per request, in the middleware that sets the pages’ shared data, with <html lang> to match:

middleware/01.session.ts
const locale = i18n.locale(c)
c.set("shared", { ...shared, locale })
c.set("headDefaults", { htmlAttrs: { lang: locale } })

Leave htmlAttrs.lang out of void.config.ts’s head, or it wins.

Plain strings with ICU-style arguments:

{name} The value; a number is formatted for the locale (1.200 in Dutch), a Date as a medium date
{count, plural, =0 {none} one {# item} other {# items}} =n first, then Intl.PluralRules’ category, then other; # is the count, formatted
{role, select, owner {Owner} other {Member}} The branch named by the value, else other

Branches can hold arguments of their own. A key missing from a locale falls back to the source message, then to the key itself. i18n.missing() lists each translation’s missing keys, for a test that keeps them complete:

expect(i18n.missing()).toEqual({ nl: [] })

A composable over the shared locale and time zone:

src/i18n.ts
import { useShared } from "@void/vue"
import { formatter, type Params } from "@recranet/flare/i18n"
import { computed } from "vue"
import { i18n, type MessageKey } from "./messages.ts"
export const useI18n = () => {
const shared = useShared()
const t = computed(() => i18n.translator(shared.locale))
const f = computed(() => formatter(shared.locale, { timeZone: shared.timeZone }))
return { t: (key: MessageKey, params?: Params) => t.value(key, params), f }
}
<h1>{{ t("overview.title") }}</h1>
<p>{{ t("overview.welcome", { name: shared.user?.name }) }}</p>
<span>{{ f.money(deal.amount) }} · {{ f.relative(activity.occurredAt, now) }}</span>

The server renders in the request’s locale and the browser hydrates with the same shared value, so they agree.

formatter(locale, { timeZone }) gives number, money (cents), date, dateTime, relative (“yesterday”, “in 2 hours”) and list. Pass the browser’s time zone for moments, or the server renders UTC.

i18n.t(c, key, params) translates in the request’s locale: an error body, a validation message. Any context with req.header() fits; without headers, the source locale.

throw new HTTPException(403, { res: Response.json({ error: i18n.t(c, "error.apiKeyLimit") }, { status: 403 }) })

An API client gets its Accept-Language. Mail and jobs have no request: i18n.translator(locale) with a locale you stored.

i18n.cookie(locale) is the cookie that sets it, i18n.cookie(null) the one that clears it (back to the browser’s languages), for document.cookie or a Set-Cookie header. i18n.override(document.cookie) reads the current choice, null for none. After setting it, refresh the page’s data (router.refresh()): the loaders and shared data run again in the new language, and <html lang> follows shared.locale on the client.

The cookie names a locale; one the app doesn’t have is ignored. Tags match by language, so nl-BE finds nl.

negotiate(header, locales) is the Accept-Language match on its own: the header’s order of preference (q), then the exact tag, its language, or another region of it.

  • src/messages.ts: English and Dutch for the shell (sidebar, breadcrumb, top bar, user menu), sign-in, the overview and the API key permission error. The other pages are English for now.
  • The user menu’s Language section: English, Nederlands, or the browser’s language.
  • lib/access.ts answers a key’s refused action in the caller’s language.
  • test/i18n.test.ts: the translations are complete, pages follow Accept-Language and the cookie, the overview’s money is Dutch, and the API error is too.
  • Why not vue-i18n, i18next or FormatJS: decision 13.
  • No locale in the URL: the pages are behind a sign-in, so there is nothing for a search engine to index per language.
  • The choice is per browser (a cookie), not stored on the user.
  • Every locale’s messages go into the browser bundle. Split them per locale when they grow.
  • The ICU subset is arguments, plural and select. No selectordinal, number skeletons or date arguments with styles; format those with formatter and pass the string.