10 KiB
Translations (i18n)
How translation keys are stored, named, and written. This applies to every runtime that consumes @rocket.chat/i18n.
Client-specific guidance — hooks, the Trans component, escaping — lives in docs/frontend/i18n.md.
Where translations live
All translations are flat JSON files in packages/i18n/src/locales/, one per language, named <language>.i18n.json (68 locales at the time of writing).
en.i18n.json is the base language. It is the only file you should edit when adding a feature:
- keys absent from a locale fall back to
en(fallbackLng: 'en'in every runtime); - keys present in a locale but not in
enare treated as stale and removed by the linter (wipe-extra-keys); - key order in every other locale is derived from
en, so reorderingenreorders all of them.
Translations for other languages arrive separately. Don't hand-write them for your own feature.
Types are generated from en
packages/i18n/src/resources.ts is a checked-in dummy. The real key union is generated into dist/resources.d.ts from en.i18n.json at build time. At the time of writing it doesn't restrict t's signature — the declaration adds overloads to i18next's TFunction rather than narrowing it, and narrowing was avoided for typecheck performance. A misspelled key is not a compile error, so verify keys by grepping the base locale.
A key you just added won't appear in the generated types until the package is rebuilt:
yarn workspace @rocket.chat/i18n build
Naming keys
The dominant convention is capitalized snake case — Sentence_case_with_underscores:
{
"Cam_on": "Camera on",
"Delete_room": "Delete room",
"You_are_offline_please_reconnect": "You are offline, please reconnect"
}
Write the key to describe the meaning, not the value, and not where it is rendered. Delete_room survives a copy change; Delete_room_red_button does not.
Reuse an existing key when the meaning is identical. Do not reuse one because the English happens to match — languages that inflect by context will need them separated, and splitting a shared key later is a breaking change for every locale.
Namespaces
A key may be prefixed with one of exactly five namespaces, separated by a dot (nsSeparator: '.'):
core (the default) · onboarding · registration · cloud · subscription
{
"onboarding.component.form.action.next": "Next",
"subscription.callout.title.limitsReached": "Limits reached"
}
An unprefixed key lands in core. Namespaces exist so a client can load a subset of the bundle, not as general grouping. The set is defined in packages/i18n/src/index.ts. Within a namespace, keys use lowercase dotted paths rather than snake case, following the existing entries.
Interpolation
Use i18next named placeholders, {{likeThis}}, with camelCase names:
{
"Room_removed": "Room {{roomName}} removed from ABAC management"
}
Two obsolete forms still exist in the base locale and must not be copied:
| Form | Status |
|---|---|
{{name}} |
✅ correct |
__name__ |
❌ auto-rewritten by the linter (replace-2-underscores) |
%s |
❌ legacy sprintf, positional; ~75 remaining, tracked by find-sprintf-params |
The sprintf form still works at runtime — both the client and the Meteor server install i18next-sprintf-postprocessor and wrap t with addSprinfToI18n — but it is positional, so a translator reordering a sentence silently swaps the values. Don't add new ones.
Some key names also embed the old marker — Added__username__to_team, __count__result_found. That's cosmetic legacy in the name only; the values use {{...}}. Don't imitate it in new keys.
Never assemble a sentence from fragments
Word order is not universal, and a translator only sees the pieces:
❌ Incorrect:
`${t('Deleted')} ${count} ${t('messages')}`;
✅ Correct — one key holds the whole sentence:
t('Messages_deleted', { count });
A key carrying a count needs plural forms too, so Messages_deleted would be defined as an object — see Pluralization.
Composing a Label: value pair from a label key and a runtime value is fine; splitting prose across keys is not.
Formatters
Placeholders accept an i18next formatter after a comma. The Intl-backed built-ins work in every runtime:
{
"Exceeded_limits": "Your workspace exceeded the {{val, list}} license limits.",
"Seats_used": "{{count, number}} seats used"
}
One custom formatter, capitalize, is registered only in the client (TranslationProvider). It exists so that a language needing a different word order can capitalize whichever word ends up first, inside the translation file rather than in code. No base key uses it yet. Don't reach for it in a key the server also renders — there it would pass through unformatted.
Pluralization
Give the key an object of plural forms and pass count. i18next picks the form using the locale's CLDR rules:
{
"message_counter": {
"one": "{{count}} message",
"other": "{{count}} messages"
}
}
For en that means one and other. Other locales have different sets — Arabic has six — which is exactly why this must not be hand-rolled:
❌ Incorrect:
count === 1 ? t('message_counter_one') : t('message_counter_other');
✅ Correct:
t('message_counter', { count });
i18next also honours a special zero form, used when a dedicated empty-state phrasing reads better than "0 items":
{
"Calls_in_queue": {
"zero": "Queue is empty",
"one": "{{count}} call in queue",
"other": "{{count}} calls in queue"
}
}
Only add zero when the wording genuinely differs; other already covers 0 in English.
Plural forms are validated per language: forms outside that language's CLDR set are stripped (wipe-invalid-plurals), and a locale missing a form that en defines is reported (find-missing-plurals).
Server-side usage
Three runtimes initialise the shared resources independently:
| Runtime | Init |
|---|---|
| Client | client/providers/TranslationProvider.tsx — en bundled, a non-English active locale fetched over HTTP |
| Meteor server | server/lib/i18n.ts — eager, all 68 locales in memory |
omnichannel-transcript service |
ee/apps/omnichannel-transcript/src/i18n.ts — same eager shape |
On the server, import the instance rather than creating one:
import { i18n } from '../../app/utils/lib/i18n';
Always pass lng
This actually exposes a gap in the server-side i18n design.
The server instance is initialised with lng: 'en' and there is no per-request language context. Omitting lng is not an error — it silently returns English. Only about a third of the ~200 server call sites pass it, so the surrounding code is not a reliable guide here.
❌ Incorrect — English regardless of who receives it:
i18n.t('Username_and_message_must_not_be_empty');
✅ Correct:
i18n.t('Username_and_message_must_not_be_empty', { lng: user.language || settings.get('Language') || 'en' });
That fallback chain — recipient's language, then the workspace Language setting, then en — is the established pattern. There is no shared helper for it yet, so it is written out at each call site.
Pick the language of whoever reads the string, which is not always the acting user: a notification, an email, or an export is rendered for its recipient.
Don't translate at the API boundary
Prefer returning a key from an endpoint and translating in the client, which is what most endpoints already do. The client knows the reader's language; the server has to be told.
Separate systems
packages/livechat has its own translations in src/i18n/, unrelated to @rocket.chat/i18n: 59 locales, plain <language>.json, nested under a single translation root key, lower_snake_case keys, and plurals as _one/_other key suffixes instead of nested objects.
None of the rules on this page — the linter included — apply there. Don't copy conventions in either direction.
What the linter enforces
yarn workspace @rocket.chat/i18n lint runs ESLint plus src/scripts/check.mts. Most findings are auto-fixable with lint:fix.
| Task | Rule |
|---|---|
sort-base-keys |
en keys sorted alphabetically, case-insensitive |
sort-keys |
every locale follows en's key order |
wipe-extra-keys |
locales may not hold keys absent from en |
wipe-invalid-plurals |
plural forms must be valid for that language (plus zero) |
find-missing-plurals |
a locale must define every plural form en defines |
replace-2-underscores |
__name__ → {{name}} |
missing-placeholders / extra-placeholders |
placeholders must match en exactly |
find-duplicate-keys |
no duplicate JSON keys |
trim-eof |
no trailing whitespace at end of file |
sort-base-keys runs before sort-keys, since every other locale's order is derived from en. A new key can go anywhere in en; lint:fix moves it into place and reorders the other 67 files to match.
Two details of the order are not alphabetical, and both are forced by JavaScript rather than chosen:
- integer-like keys come first (
"500"), becauseJSON.parsehoists them to the front of the object no matter where the file puts them; - keys differing only in case (
Private/private, 69 such pairs) tie under the case-insensitive comparison and are broken by a plain code-point comparison, so the order stays canonical.
One task is defined but excluded from the default run, so it won't fail your build: find-sprintf-params, which flags the leftover %s values in en (6 keys today). It is excluded because of an existing backlog, and shouldn't be "fixed" as a side effect of a feature PR. You can inspect it without changing anything:
cd packages/i18n && node --experimental-transform-types ./src/scripts/check.mts -t find-sprintf-params
Committing
Translation-only changes use the i18n: commit type, per the pull request template.