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.
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.
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 buildThe 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.
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.
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.
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.
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.
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).
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';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.
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.
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.
yarn workspace @rocket.chat/i18n lint runs ESLint plus src/scripts/check.mts. Most findings are auto-fixable with lint:fix.
| Task | Rule |
|---|---|
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 |
Two tasks are defined but excluded from the default run, so they won't fail your build. Both are excluded because of an existing backlog, and neither should be "fixed" as a side effect of a feature PR:
sort-base-keys—enkeys alphabetically, case-insensitive.encurrently reports ~2000 violations, and because every other locale's order is derived fromen, running--fixwould rewrite all 68 files. Put new keys in roughly the right alphabetical place by hand; leave the global sort to a dedicated PR.find-sprintf-params— flags the ~75 leftover%svalues.
You can inspect either without changing anything:
cd packages/i18n && node --experimental-transform-types ./src/scripts/check.mts -t sort-base-keysTranslation-only changes use the i18n: commit type, per the pull request template.