A section in a chunk of its own hydrates after the provider may have loaded the reader's catalog. useI18n handed it the Arabic words over English HTML, and React threw the tree away and drew it again on the client (hanzo.ai's landing in Arabic: "Hydration failed because the server rendered text didn't match", #418). useI18n answers the provider's starting locale with no catalog (Served) while its component hydrates, through useSyncExternalStore's server snapshot, and the reader's words a render later; a component mounted on the client gets them at once. test:browser hydrates a page held up by a section whose chunk arrives after the catalog, and reports the mismatch without this change.
@hanzo/i18n
Hanzo's localization in one package: a registry of 79 locales, the negotiation every surface shares, a React provider and language picker, right-to-left layout, and the interface catalogs, each loaded only when chosen.
pnpm add @hanzo/i18n
React
import { I18nProvider, LanguagePicker, useI18n } from '@hanzo/i18n/react'
import { bundled } from '@hanzo/i18n/bundled'
import '@hanzo/i18n/rtl.css'
<I18nProvider loadCatalog={bundled} userId={subject} account={{ base: 'https://hanzo.id', token }}>
<LanguagePicker />
<App />
</I18nProvider>
function Title({ name }: { name: string }) {
const { t } = useI18n()
return <h1>{t('Welcome back, {name}', { name })}</h1>
}
t('English source', { values }) translates authored interface copy; a message a
catalog lacks renders its English. Never pass user content, organization names, code
or API responses through it. Rendering never calls a model.
- Choice. Saved per account in this browser, with a separate signed-out choice;
other tabs follow. With
account, it is also read from and written to IAM's/v1/iam/preferences(onlylocale), so it follows the person across devices, and the account's choice is kept in this browser so the next load opens in it. A failed save keeps the device choice and reports it; a failed catalog load keeps the current language and offers a retry. - First visit. Signed out, the browser's languages pick one; signed in,
initialLocale(English). A server prerender is English and hydrates; withbootScriptin the head,<html lang dir>is right before the first paint. - Review. Locales in
catalogs/review.jsonare machine translations waiting for a native reader. They load by code, so a person who chose one keeps it, but the picker does not offer them and a browser language does not select them.<LanguagePicker review />offers them. - Partial. A locale
catalogs/review.jsonmarkspartialships before a run has reached every key; a key it lacks reads English.{ "why", "partial" }is a review locale;{ "partial" }alone is an offered one, still offered. - Where the words come from.
loadCatalogis required.bundled(@hanzo/i18n/bundled) loads the catalog this package ships, one lazy chunk per locale, whole. A whole catalog is every key of every reader, so a host that serves its catalogs in parts passes its ownloadCatalog(code, scope)andscope(the part of the site shown): the provider asks for that part with the language, and for each new part once on a move, adding what it answers to what is shown. Such a host never imports./bundled, so its build carries no catalog chunk. - Right-to-left. Every locale in Arabic or Hebrew script (Arabic and its regional
variants, Persian, Dari, Hebrew, Urdu, Pashto, Central Kurdish, Sindhi, Uyghur,
Punjabi in Shahmukhi) sets
<html lang dir="rtl">. Lay out with logical properties (margin-inline-start,text-align: start).rtl.cssselects on:dir(rtl), so it holds on a right-to-left page and inside anydir="rtl"region: it keeps code, URLs, email inputs and[data-i18n-ltr]left-to-right, and mirrors[data-i18n-mirror]icons. Language names are in their own scripts, never flags. - Scripts. Load a Noto family per script you serve. Urdu and Punjabi (Pakistan) read
best in Nastaliq, which needs
line-heightof about 2.2; Noto Naskh Arabic is the compact choice. Persian spells with the zero-width non-joiner (U+200C): do not strip it. Burmese, Khmer, Lao and Thai put no spaces between words, so keepword-break: normaland let the browser's dictionary break lines; their stacked marks wantline-heightof 1.7 or more. Georgian has no letter case: do nottext-transform: uppercaseit.
Before the first paint
A static export arrives in English. Put bootScript() first in the head and the page
opens in the reader's direction, before any bundle runs:
import { bootScript } from '@hanzo/i18n/boot'
// the root layout: first in <head>
<html lang="en-US" suppressHydrationWarning><head><script dangerouslySetInnerHTML={{ __html: bootScript() }} />…</head>…</html>
It sets <html lang dir> to what the provider last showed in this browser, or else the
browser's languages negotiated as resolve does, and touches nothing else. About 1.5 KB,
no React, safe in a server component.
Send sentences, not fragments
A string is translated alone. A sentence split around a link
("By continuing, you agree to Hanzo's " + <a>Terms</a>) cannot be put in another
language's order: Arabic places the owner after the noun, inside the link, so the
fragment's translation drops "Hanzo". Pass the whole sentence as one string with the
link as an argument. Give t an element and it returns a node, the sentence in the
reader's order with the element where the translation puts it:
t('By continuing, you agree to the {terms}.', {
terms: <a href="/terms">{t("Hanzo's Terms of Service")}</a>,
})
A figure is an argument too, never concatenated: t('{n} tokens a second', { n }).
A server component: <T>
<T> is t as an element, so a server component (which cannot call a hook) hands
its English to the client to translate:
import { T } from '@hanzo/i18n/react'
<T>Plans that grow with you.</T>
<T values={{ n: <strong>{count}</strong> }}>{'{n} tasks left'}</T>
The children are the key, exactly as the compiler hands them over: JSX text written
over several lines is one line joined by single spaces, entities decoded. A sentence
with arguments is a string in braces, since {n} in JSX text would be an expression.
<T>Hello {name}</T> gives T a list, not a key; write values instead.
Figures, money, dates and lists
format is Intl at the reader's locale. Options are Intl's, passed through, so in
English every helper writes what toLocaleString('en-US', options) writes:
const { t, format } = useI18n()
format.number(1234567) // 1,234,567 · ١٬٢٣٤٬٥٦٧ in ar-EG
format.money(1234.5, 'USD') // $1,234.50
format.percent(0.929, { maximumFractionDigits: 1 }) // 92.9%
format.date(iso, { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' }) // Oct 22, 2026
format.relative(updatedAt) // 3 minutes ago · { numeric: 'auto' } for yesterday
format.list(['Chat', 'Dev', 'Kai']) // Chat, Dev, and Kai
t('{n} tokens a second', { n: format.number(rate, { maximumFractionDigits: 0 }) })
The locale decides its digits and calendar as Intl does: ar writes Latin digits,
ar-EG and ar-SA Arabic-Indic, fa Persian digits and the solar calendar. A
caller who needs otherwise says so in options (numberingSystem, calendar).
relative picks the largest unit the span fills, from now (the clock by default),
or the unit asked for. A moment Date cannot read throws a RangeError, never the
words "Invalid Date". Outside React, format(locale) from @hanzo/i18n is the same.
Figures in right-to-left prose
In a right-to-left locale each argument is isolated: a string argument is wrapped in
Unicode's first-strong isolate (isolate), an element in <bdi>. So 1,150–1,400
still reads 1,150 then 1,400, and ~43 tok/s does not become tok/s 43~. Everything
format writes is isolated the same way, a list item by item, so Latin names in an
Arabic list read right to left; handed to t, it is not isolated twice. A figure
drawn outside any sentence and not from format needs the same: mark it
data-i18n-ltr (on an inline element) or wrap it in <bdi>.
Copy written as data
A menu label written in a data file is translated where it is drawn. Mark it with
phrase so the extractor finds it (it reads t(…), phrase(…) and <T>); phrase returns its argument:
import { phrase } from '@hanzo/i18n'
export const MENU = [{ label: phrase('Pricing'), href: '/pricing' }]
// …
<a href={item.href}>{t(item.label)}</a>
Everywhere else
import { locales, resolve, direction, translate } from '@hanzo/i18n'
import { negotiate, links, sitemap } from '@hanzo/i18n/server'
import { detect } from '@hanzo/i18n/native'
resolve(['zh-TW', 'en']) // 'zh-Hant' — script decides, then region
resolve('es-CO') // 'es-419' — the variant that serves Colombia
negotiate(request.headers.get('accept-language'))
links('/pricing', { origin: 'https://hanzo.ai' }) // reciprocal hreflang set, x-default included
detect() // the device's language, negotiated the same way
Catalogs are also importable directly: @hanzo/i18n/catalogs/ar.json.
Locales
79 locales: am, ar, ar-EG, ar-LB, ar-MA, ar-SA, hy, as, az, bn, bho, my, ceb, ckb, zh-HK,
zh-Hans, zh-Hant, da, fa-AF, nl, en-US, fil, fi, fr-CA, fr-FR, ka, de-DE, gu, ht, ha, he,
hi-IN, id-ID, it-IT, ja-JP, jv, kn, kk, km, ko-KR, ku, ky, lo, ms, ml, mr, mn, ne, nb, or,
om, ps, fa, pt-BR, pt-PT, pa, pa-PK, ru, sd, si, so, es-AR, es-419, es-MX, es-ES, su, sw,
sv, tg, ta, te, th, tr, tk, uk, ur, ug, uz, vi. Those in catalogs/review.json wait for a
native reader and are not offered until one clears them. Every translation is
machine-made and validated (placeholders, protected names in Latin script, the locale's
own script, the right Chinese); catalogs/provenance.json records the model and run
behind each message.