Internationalization
We use i18next and react-i18next to handle i18n in O3. Each frontend module has its own package-level translations directory, such as translations/en.json. Strings in the codebase are translated using the t function exported by react-i18next. The t function takes a key and an optional default value. Run the extract-translations script after adding or removing translatable strings. This uses the i18next-parser library under the hood. The script reconciles the keys in translations/en.json with the keys used in the codebase; it does not overwrite the value of a key that already exists. translations/en.json is the source of truth for the English strings that ship, and it is the source file that Transifex translates from. We then rely on the integration with Transifex to manage the translation files for each locale. You can read more about how i18n is implemented in OpenMRS here .
The following are some guidelines to follow when handling i18n in O3:
-
Do not manually edit locale-specific translation files such as
es.jsonorfr.json. The Transifex integration updates those for each locale via automated pull requests.translations/en.jsonis different: it holds the English source strings, and it is the file to edit when you want to change text that users see. -
The default value in a
t()call is a fallback, not the string that ships. When a key exists intranslations/en.json, i18next returns the value from that file and ignores the inline default. Given"cameraError": "Camera Error"inen.json, the call below rendersCamera Error:// Renders "Camera Error", because the value in en.json wins. t("cameraError", "Camera error");Two things follow from this:
- To change user-facing English, edit
translations/en.json. Changing only the inline default has no effect on what users see. Becauseen.jsonis the Transifex source, changing a value there does invalidate existing translations for that key, which is the correct outcome when the English wording genuinely changes. - Keep the inline default and the
en.jsonvalue identical.extract-translationswill not reconcile them once the key exists, so when they disagree the code silently misleads the next person about what the UI actually says.
- To change user-facing English, edit
-
A passing unit test does not prove what the UI displays. The shared
react-i18nexttest mock resolvest(key, defaultValue)todefaultValue, so tests assert the inline default while the running app servesen.json. If you change an inline default, expect assertions on the old text to fail, and update them. -
Use the useTranslation hook to translate strings in your components.
import { useTranslation } from "react-i18next"; const AddVitalsButton = () => { const { t } = useTranslation(); const addVitalsLabel = t("addVitals", "Add vitals"); return ( <Button kind="ghost" renderIcon={Add} iconDescription={addVitalsLabel} onClick={launchVitalsBiometricsForm} > {t("add", "Add")} </Button> ); };The corresponding keys and strings for the code above should look like this:
en.json"add": "Add", "addVitals": "Add vitals" -
Use the Trans component to translate strings that contain HTML tags.
import { Trans } from "react-i18next"; const VitalsHeader = () => { const { t } = useTranslation(); return ( // other code omitted for brevity <Trans i18nKey="overOneWeekOldVitals"> <span> These vitals are <strong>over one week old</strong> </span> </Trans> ); };The corresponding keys and strings for the code above should look like this:
en.json"overOneWeekOldVitals": "<0>These vitals are <1>over one week old</1></0>", -
To handle pluralization, use the following pattern:
// If there's only one risk flag, the string "1 risk flag" is displayed. // If there are multiple risk flags, the string "{{count}} risk flags" is displayed // e.g. "3 risk flags". <span className={styles.flagText}> {t("flagCount", "{{count}} risk flag", { count: riskFlags.length, })} </span>The corresponding keys and strings for the code above should look like this:
"flagCount_one": "{{count}} risk flag", "flagCount_other": "{{count}} risk flags"_oneand_otherare the two forms English has, and the suffixes are the format i18next expects. At runtime i18next picks the category for the target locale usingIntl.PluralRules, which for Russian meansone,few,many, andother, and for Arabic additionallyzeroandtwo.Our translation pipeline does not currently supply those extra categories. i18next represents a plural as several flat keys, and our Transifex resources round-trip them as exactly that:
flagCount_oneandflagCount_otherarrive as two unrelated strings, with nothing to tell Transifex they are one plural family. So no per-category forms are offered to translators and none are created, and the locale catalogues we ship carry only the two keys extracted from English. A count that falls into a category the catalogue does not have falls back to English. Inopenmrs-esm-patient-charttoday,flagCountrenders2 risk flagsand5 risk flagsin Russian, Arabic, and Polish.This is a limitation of our flat-key import and export, not of the file format: Transifex’s JSON format does support plurals when a single key’s value carries an ICU plural message, and it expands those into each language’s CLDR categories. Adopting that would mean changing how O3 represents plurals on both sides of the pipeline, so until then keep writing
_oneand_other, and do not hand-add_fewor_manytoen.json.Because of that, prefer a platform formatter over plural keys whenever the quantity is a duration or another measured value.
formatDurationandformatDurationBetweenare backed byIntl.DurationFormat, so they apply each locale’s own plural rules and connectors with no translation keys to maintain and no dependency on the export pipeline. See Formatting dates. Reach for plural keys when the counted thing is domain vocabulary that has to be translated anyway.Each form has to be grammatical on its own for the category it serves, and has to keep every placeholder its message needs. Forms that are byte-identical are not automatically wrong, since English invariant nouns like
{{count}} fishread correctly either way, and plural selection can change grammar elsewhere in a sentence without the number appearing. What is wrong is a form that does not read correctly for its own category, which is how1 results for "aspirin"shipped. -
To interpolate variables in a string, use the following pattern:
// Using variables in translations const WelcomeMessage = ({ patientName }) => { const { t } = useTranslation(); return ( <p>{t("welcome", "Welcome, {{name}}!", { name: patientName })}</p> ); };The corresponding translation key should look like:
en.json"welcome": "Welcome, {{name}}!" -
Strings that live in a metadata object rather than in JSX need a key of their own and an extraction comment. A dashboard link is the common case.
DashboardExtensionrenders its title with{t(title)}, so whatever string sits intitleis used as the translation key, and i18next-parser cannot see it because a plain object property is not at()call. Put a key intitle, and declare it in a comment next to the export so it gets extracted:src/index.ts// t('patientLists', 'Patient lists') export const patientListDashboardLink = getSyncLifecycle(createDashboardLink(dashboardMeta), options);src/dashboard.meta.tsexport const dashboardMeta = { path: 'patient-lists', slot: 'patient-lists-dashboard-slot', title: 'patientLists', // the key, not the text } as const;The key has to live in the
en.jsonof the module that registers the link, because that is the namespace the extension renders in.Putting the English text in
titlealso works, sincet()falls back to the key when it finds no entry, and some modules do it. It costs you something though: the text becomes the key, so changing the wording changes the key and orphans every translation of the old one. Prefer a stable key.There are two common ways to get this wrong which both fail quietly. If the comment declares a different key from the one in
title, the extracted key is never looked up and the title renders as its own raw text. And if the component renders{title}instead of{t(title)}, no lookup happens at all, so the comment implies a translation that never occurs. -
Use sentence case for UI text, and keep one key per stable meaning and grammatical context. Reuse a key only when both match. English spelling is not a safe identity rule: the same word can need different target text as a noun, a verb, a heading, or a full sentence, which is why i18next supports context variants . Our shared i18next-parser config sets
contextSeparator: false, so context keys do not survive extraction in O3; use separate keys instead. Transifex’s translation memory already offers exact matches for repeated source text, so a second key for a genuinely different context costs a translator little, whereas collapsing two contexts onto one key cannot be undone in translation.What to avoid is the accidental duplicate: two keys holding the same text for the same meaning in the same context, such as
errorStartingVisitandstartVisitError, which both readError starting visitand both title the snackbar shown when starting a visit fails. Those fragment translation memory and drift apart over time. Pairs that look similar are often not duplicates at all:visitTypelabels a table column whilevisitType_titleheads a section of the visit form, andmedicationsandmedications__lowerdiffer because one stands alone and the other sits inside a sentence. Check the call sites before merging two keys. -
Do not pass arbitrary backend display text through
t(). A concept name arrives from the API as free text with no key inen.json, sot()returns it unchanged while making the call site look translated:// Pointless: a concept's display name is free text, so there is no key to look up. t(concept.display);Passing a closed set of backend codes through
t()is a different matter, and is fine as long as you own a key for every value. Allergy severity works this way: the API returnsmild,moderate, orsevere, and the allergy form declarest("mild", "Mild")and its siblings so the keys get extracted. Every locale catalogue therefore contains the keys, which is what lets a translated locale localize them. Either map the values onto keys you own, or render the text as it came.