Internationalization
Writing a string that can be translated, translating it, and keeping right-to-left layouts working.
Write a string
Every string a user reads goes through t(), with its English inline. Get t from the hook or function for where the code runs:
| Where | Get t with |
|---|---|
shared-ui components | useT("ui") |
web-app and desktop | useT("app") |
web-static | useT("site") |
blog and docs servers | await getT(language, "blog") or "docs" |
email-templates | createT(language, emailResources(language), "email") |
A one-liner uses its English as the key:
const t = useT("app");
<PageHeader title={t("Profile")} />;
A paragraph gets a named key, <namespace>.<area>.<role>, with the English as its default. Then fixing a typo doesn't drop its translations.
t(
"app.orgUsage.intro",
"Chatbot usage by member, per UTC month. When the organization or a member reaches a limit, the chatbot stops answering them until the next month.",
);
Put values in placeholders, not template strings: t("Only {{max}} allowed.", { max }). For markup inside a sentence, use Trans with a named key.
Call useT or getT in the file where the strings are. Extraction reads the namespace from that call, so a t passed in as a parameter lands its strings in the wrong catalog.
Backend errors
Mark any message a user will read with msg(), and throw it with userError:
throw userError("FORBIDDEN", msg("That role ranks above your own."));
The client shows it with useErrorText, which looks the English up in the errors catalog and falls back to it:
const errorText = useErrorText();
setError(errorText(error, t("Couldn't save that change.")));
Translate it
- Run
yarn workspace i18n extract. The new key appears ini18n/locales/en, and empty infr,es, andhe. - Translate it in
i18n/locales/<language>/<namespace>.json, or leave it empty to show English. - For a named key, run
yarn workspace i18n stamp <language> <key>for each language you translated. It records the English the translation was made from.
Keep the terms consistent with the existing catalogs. French uses vous, Spanish uses tú, and Hebrew uses gender-neutral forms.
What CI checks
The Translation catalogs in sync with the source step in the Lint job runs four commands. Run them yourself before pushing:
| Command | Fails when |
|---|---|
yarn workspace i18n extract:check | A t() or msg() isn't in the catalogs, or a removed key still is |
yarn workspace i18n check:stale | A named key's English changed since its translation was made |
yarn workspace i18n lint:strings | Interface text skips t() |
yarn workspace i18n test | The i18n workspace's own tests fail |
A missing translation doesn't fail anything. It shows the English, by design.
To excuse text that isn't copy, such as a brand name or a path, put a comment on the line before it:
{
/* i18next-instrument-ignore-next-line -- the product name */
}
Dates and numbers
Format through useLanguage(), not toLocaleString():
const { formatDate } = useLanguage();
It formats with the active language and the browser's region.
Right to left
Hebrew mirrors every layout. To keep that working:
- Use logical CSS.
margin-inline-start,padding-inline-end,inset-inline-start, andtext-align: start, neverleftorright. - Wrap machine values in
Mono. It keeps a path or ID in its own direction. - Mark text a person wrote. Chat messages, posts, and other user content get
dir="auto", so English inside a Hebrew page keeps its punctuation in place.
Check a change in Hebrew: pick it from Storybook's Language toolbar, or open a public site under /il.
Related
- Internationalization — what's translated, and what isn't.
- i18next — why this library.
- CI/CD — the jobs that run the checks.