I18n
Flat key catalog, one JSON file per locale. Loud misses. Intl.* for anything that is not a string.
Zero hardcoded user-facing strings — every string an end user can read goes through t(). A literal in JSX or in an error message is a lint failure and fails x verify.
Catalogs
One file per locale, flat dot-keys after load, nested for authoring.
packages/i18n/src/catalogs/en.json # framework strings — ships inside @ultimat3/i18n
packages/i18n/catalogs/en.json # the app's own catalogs, same shape
packages/i18n/catalogs/es.json{
"nav": { "home": "Home", "billing": "Billing" },
"post": {
"published": "Published {title}",
"count_one": "{count} post",
"count_other": "{count} posts"
}
}| Rule | Detail |
|---|---|
| Authoring | nested objects; flattenCatalog produces nav.home, post.published |
| Key segments | /^[A-Za-z0-9_-]+$/ — anything else is X_CATALOG_INVALID |
| Leaves | strings only. An array or a number leaf is X_CATALOG_INVALID |
| Collisions | a nested branch and a dotted key that flatten to the same path throw |
| Merge order | framework catalog first, app catalog last — apps override framework keys. Under en only: the framework catalog is English, so defineCatalogs seats it under core's DEFAULT_LOCALE (en) and nowhere else. An app shipping only es renders ⟦errors.notFound.title⟧ for a framework key it has not translated, which is the golden rule working — it used to be served English with isMiss reading false, a fallback locale chain wearing registration as a disguise |
en | the reference locale. Its key set is what every other locale is diffed against |
t()
import { t } from '@ultimat3/i18n';
export function billingHeading(count: number): string {
return t('post.count', { count });
}| Aspect | Behavior |
|---|---|
| Signature | t(key: string, vars?: TranslateVars): string — TranslateVars is Record<string, string | number | boolean> plus optional count |
| Locale | ambient, read from the ALS request context. t() never takes a locale argument |
| Miss | returns ⟦key⟧. Never throws, never falls back to another locale, never returns the key bare |
| Typed keys | keys are generated into a union from the en catalog; an unknown key is a type error |
| Introspection | t.has(key), t.raw(key), t.keys(); isMiss(rendered) for assertions in tests |
| No concatenation | t('a') + ' ' + name is banned — put the slot in the string: t('a', { name }) |
Word order differs per language, so a concatenated sentence is untranslatable by construction. One key = one complete sentence or label.
Interpolation
| Form | Renders |
|---|---|
{name} | String(vars.name) |
{{ / }} | a literal { / } |
{name} with no vars.name | ⟦name⟧ — loud, same reason a missing key is loud |
t() interpolates on every call, with vars or without: t('greeting') on Hello {name} is Hello ⟦name⟧, exactly as t('greeting', {}) is, and {{ is unescaped either way. The template itself — to hand a client island its plural forms, say — is t.raw(key), never t(key).
Pluralization
CLDR categories via Intl.PluralRules, never an English n === 1 branch.
| Locale | Categories it needs |
|---|---|
en, de, es | one, other |
pl, ru, uk | one, few, many, other |
ar | zero, one, two, few, many, other |
ja, zh, ko | other only |
Pass count; the translator picks the key. Candidates, most specific first: key_<category> → key_plural → key_other → key. For count === 1: key_one → key. _plural is the two-form authoring shortcut; the _<category> suffixes are what a 3+ form locale needs.
Ordinals and select
As of 2026-11-06. The suffix mechanism, two more families; no ICU syntax. One selection
(selectMessageKey) serves t() and a mail's render alike.
| Call | Reads, in order |
|---|---|
t('place', { ordinal: 2 }) | place_ordinal_<category> by CLDR's ordinal rules (en: 1 one, 2 two, 3 few, 4 and 11 other), then place_ordinal_other |
t('invite', { select: 'female' }) | invite_select_female, then invite_select_other |
t('invite', { select: 'female', count: 2 }) | each of those stems with count's plural chain: invite_select_female_other, … |
A family the catalog lacks falls through to the key's own plural, as before. A select value is a
key segment ([A-Za-z0-9-], ≤ 32) or it selects nothing. The typed key admits the stem (invite),
as it does a plural stem. x i18n check counts every member used through its stem, a locale owes
the stem and not each value (a language without gender defines invite_select_other alone), and a
family with no _other is X_I18N_FAMILY_INCOMPLETE, red in the i18n step.
Loud misses
| Surface | Behavior on a missing key |
|---|---|
| dev render | ⟦post.published⟧ in the DOM — visible in the page and in review screenshots |
x verify | check 8 (SEO + i18n) fails with X_CATALOG_MISSING_KEYS |
| CI | same failure, --json shaped |
| Tests | isMiss(t('k')) is the assertion — never assert on '' |
Silently falling back to English is what makes a half-translated app ship. The framework does not do it.
Strings in an island
t()'s catalog does not cross into an island, and a translator is not a prop. An island's own copy
arrives already translated, as text. When it needs keys — t(key, { count }) after the page
rendered, or the design system's ui.* strings — the page sends a slice and the island builds a
translator over it (As of 2026-10):
| Side | Call |
|---|---|
| page (server) | catalogSubset(t, ['app.post.likes']) from @ultimat3/i18n; uiCatalog(t) from @ultimat3/ui for every ui.* key |
| island | subsetTranslator(props.strings) from @ultimat3/i18n/subset; <UiProvider t={subsetTranslator(props.ui)}> |
The slice carries every plural variant of each key, and a key it lacks renders ⟦key⟧ in the
island exactly as on the server.
A form's validation messages key on the issue's rule, never on its English message — the
framework's diagnostic text changes without a semver signal, the rule names do not
(SCHEMA_RULES, @ultimat3/schema). Ship one key per rule in the slice and pass params:
messageFor: (issue) => t(`form.${issue.rule}`, issue.params) — form.string.min → At least {min} characters. A third-party schema names no rule; fall back to a generic key for those.
Commands
| Command | Does |
|---|---|
x i18n add <locale> | create catalogs/<locale>.json seeded with every default-locale key, each value copied inside ⟦…⟧ |
x i18n sync <locale> | add keys the locale is missing; a key it already has is never overwritten, translated or not; never deletes |
x i18n check --json | extract keys from source, diff against every catalog and against en, report missing / unused / dynamic / slot mismatch / untranslated |
Every command supports --json. add and sync copy the source value inside the marker, so the text sits where the translation goes and x i18n check counts the key as missing until a person replaces it. Source means every shape below; a key only a mail or a push names is a key.
| Shape in source | Key |
|---|---|
t('k'), $t('k'), translate('k') | first argument |
blocks.heading, paragraph, button, callout, detail | first argument |
titleKey:, bodyKey: | a literal value |
subject: | a literal value, in a file that names defineMail |
Any other value of these is listed dynamic.
check reports five separate things, and three of them fail the command:
| Reported | Meaning | Exit |
|---|---|---|
missing | a key source names, or a key en defines (compared by plural stem, so pl's _few and ja's lone _other answer it), that the locale does not define | non-zero, one X_CATALOG_MISSING_KEYS finding per locale |
unused | a key the locale defines that no shape above names | 0 — informational |
dynamic | t(plans.${plan}.name), a key the static extractor cannot resolve | 0 — informational, with file, line and column |
slot mismatch | two locales define one key with different interpolation slots — the slot sets are compared by name, so the same slots in another order are not a mismatch | non-zero, one X_CATALOG_SLOT_MISMATCH finding per direction |
untranslated | a locale still carries the reference locale's value — byte-identical to it and longer than 24 characters | non-zero, one X_CATALOG_UNTRANSLATED finding per key |
The last two are the parity rules, and both compare a locale with the reference locale en (As of 2026-10). A key en defines and another locale lacks is a missing key, not a parity finding, and that holds for a key only reached dynamically (t(plans.${plan}.name)). A key only another locale defines is unused there. A ⟦key⟧ placeholder is a missing key too — never a copy. Every finding names the locale, the catalog key and the file packages/i18n/catalogs/<locale>.json; x verify's i18n step and x i18n check report them from one implementation, so the command and the gate answer about an app with one voice.
Before any of the five, check resolves @ultimat3/i18n from the app root, from every workspace and from the CLI itself. Two real directories is X_PACKAGE_DUPLICATED, and every registration gap is then reported as the install's fault with the install's fix (pin one version, bun install) — not as a misplaced defineCatalogs(): each copy keeps its own registry, so the app registers into one and the check reads the other.
A dynamic call contributes its static head (plans.) as a runtime-key prefix, so a key only ever reached that way is never listed unused — otherwise the list reads as "safe to delete" over live keys. An expression with no static head (a ternary over literals, a bare variable) contributes nothing: a guessed prefix would suppress real gaps.
Locale routing
As of 22.3.0. The routed locales are routedLocales(): the ones defineCatalogs() declared, default first. An app that declared none routes its fallback alone — no prefix, no per-locale files.
Path (default es-co, also en) | Locale | Answer |
|---|---|---|
/precios | es-co | the /precios route |
/en/precios | en | the /precios route — the prefix is stripped before the route table is matched. An isr page keeps the prefix in its store key and is revalidated by that route's ttl and tags (Routes) |
/es-co/precios | — | 301 to /precios (308 for a non-GET); the default is never prefixed |
/fr/precios | — | 404: fr is not routed, so /fr/precios is an ordinary path |
/en/nothing | en | 404, its error page in English |
| Rule | Detail |
|---|---|
| Prefix | authoritative on every surface: it beats ?locale=, the cookie, the user and Accept-Language. Stripped in @ultimat3/http's context stage, never by middleware — middleware wraps matched routes only |
site/ | the URL alone (RouteMeta.localeSource: 'path'). Unprefixed is always defaultLocale — no Accept-Language negotiation, because a CDN serves / as one file — and Vary drops accept-language |
app/ / api/ | unprefixed keeps query → cookie → user → header, configurable via configureLocales({ order }). The user rung is the actor's saved locale, which your authenticate hook sets (userActor({ id, locale, tz })); @ultimat3/http re-resolves once auth has run. An explicit choice outranks the browser: ?locale= is how an email preview link pins a locale, the cookie is what the language switcher writes, and Accept-Language is only the fallback when nobody has chosen. An order without 'header' never reads it, and the response drops accept-language from Vary (As of 22.10) — a CDN no longer stores one copy per browser language of a page that cannot differ by it |
| Static export | x build --target static renders each site/ page once per routed locale, in that locale's context: index.html (lang="es-co"), en/index.html (lang="en"), en/precios/index.html. Every static host serves /en/precios from that layout with no rewrite |
| Cookie | x_locale, written only by an explicit language switcher |
| Unsupported tag | skipped, not thrown — a stale cookie must never 500 a page. assertSupportedLocale throws X_LOCALE_UNSUPPORTED where an unknown tag is a caller bug |
| Direction | currentDirection() returns 'rtl' for ar, he, fa, ur, … and is written to <html dir> |
| API | From | Answers |
|---|---|---|
routedLocales() | @ultimat3/i18n | the locales a URL may name, default first |
localizedPath(path, locale) | @ultimat3/i18n | /precios + en → /en/precios; the default → /precios; / + en → /en/; an existing prefix is re-spelled, never doubled |
splitLocalePrefix(pathname) / unlocalizedPath(pathname) | @ultimat3/i18n | the prefix split off, or the bare path |
localizePath(path, locale, locales, defaultLocale) | @ultimat3/core | the same arithmetic over an explicit list — for browser code, which must not import the i18n barrel |
<LocaleSwitcher locales={routedLocales()} path={pathname} /> | @ultimat3/ui | links to this page in each locale; hrefFor overrides, defaultLocale defaults to locales[0] |
meta receives locale, localizedPath(locale) and alternates ({ locale, path } per routed locale). The head then carries, with no route code:
| Tag | Value |
|---|---|
<link rel="canonical">, og:url | absolute, against APP_URL / SITE_ORIGIN / site.origin (Configuration) |
<link rel="alternate" hreflang> | one per routed locale in BCP 47 region form (es-co → es-CO), plus x-default → the default locale's page. A route's own alternates replace the set whole |
og:locale / og:locale:alternate | es_CO form — language and region only, so zh-hant-tw is zh_TW; the document's locale, then every other one |
sitemap.xml lists every page once per locale, each with the same xhtml:link cluster. A single-locale app emits none of the cluster.
Numbers, dates, money
Never hand-rolled, never string-formatted in a component.
| Value | Route |
|---|---|
| Number / percent | Intl.NumberFormat with the request locale |
| Date / time | Intl.DateTimeFormat with the request locale and an explicit IANA timeZone → Timezones and dates |
| Currency | Money = { minor, currency } formatted at the edge → Money |
A colour is a token, a string is a key, a date needs a zone, an amount is minor units. Same four rules everywhere.
Errors
| Code | Cause | Fix |
|---|---|---|
X_LOCALE_UNSUPPORTED | tag is not in the supported set | x i18n add <locale> |
X_CATALOG_MISSING_KEYS | a locale file is missing keys source names or en defines (names up to 12 of them) | x i18n sync <locale> |
X_CATALOG_INVALID | non-string leaf, bad key segment, or duplicate flat key | x i18n check --json |
X_CATALOG_SLOT_MISMATCH | two locales define one key with different interpolation slots | x i18n check --json — the finding names the locale, the key and the slots that differ |
X_CATALOG_UNTRANSLATED | a locale still carries the reference locale's value | x i18n check --json — the finding names the locale and the key still carrying the reference value |
X_PACKAGE_DUPLICATED | two installed copies of @ultimat3/i18n, each with its own registry | pin one version in the package.json the finding names, then bun install |
Full list: Error codes.
Rules
- Zero hardcoded user-facing strings. Everything through
t(). - One key per complete sentence. Never concatenate translated fragments.
enis the reference key set; other locales are diffed against it, never the reverse.- A miss is
⟦key⟧and a red build, never English. x i18n syncadds; deletion of a key is a deliberate edit in a PR.- Supported locales live in
app.config.ts;SUPPORTED_LOCALESis the framework's own set, which apps narrow or extend.
Source: packages/i18n/src