Translating React Apps with i18next
Overview
Translation is usually the most obvious step in localization (l10n) and internationalization (i18n) efforts where applications are adapted to fit the needs of users in specific regions or markets. It is also the most standardized process and most organizations follow similar processes with similar tools to get their frontend strings translated.
This guide focuses on translation and the content here is intended to connect the dots between various i18next library documentations while providing additional code required to get an app translated inside Create React App. I found that while many other guides exist, very few of them explain why certain processes or tools are required. I will try to provide the necessary context for both technical and user experience related decisions as well as internationalization best practices whenever possible. This guide assumes you have working frontend knowledge including JavaScript, React, NPM, etc. Full example code will be provided in a GitHub repo linked at the end.
Even though this guide targets Create React App, it should be applicable to most React apps. I will try to note the differences whenever possible. Please leave suggestions in the comments.
What this guide will cover:
- Why i18next
- Initial setup
- Writing translations
- User language and locale detection
- Extracting strings for translation
- Async load translated strings
- Switching from one language to another
- Pluralization
- ICU JSON syntax validation
- Fallback scenarios
- Smartling processes for getting content translated
What this guide will not cover:
- Adapting visuals to different cultures and identities — People relate to products better when their identities are represented in photos and graphics
- Modifying content to suit the tastes of different markets — Humor and idioms don’t always translate
- Adjusting feature sets for different consumption habits — Some Europeans prefer SEPA direct debit over credit card payments
- Adapting designs and layouts to properly display translated text — German words can be very long, Chinese characters can be very dense, Arabic and Hebrew are read right to left
- Converting to local units such as currencies and units of measure — Most of the world do not use U.S. Dollars or inches
- Using proper local formats for date, time, addresses, and phone numbers — Some use 24 hour time, country and area code for phone numbers differ
- Adhering to local regulations and legal requirements — Cookies and user data are regulated by GDPR in Europe
- Technical infrastructure changes — Setting up servers and content delivery networks closer to users will give users faster access more reliability
Why i18next?
When I was looking for a set of translation tools for a project, I was measuring them based on a few criteria: ease of use, vendor integration, bundle size, feature set, and reliability. It should be easy to set up and use, cover almost all cases, and require little maintenance.
Available open source libraries:
- Format.js — Library with extensive but relatively young ecosystem that does almost everything. Unfortunately, it has many quirks and unresolved edge cases with how the tools work together.
- Globalize.js — Comprehensive library that covers every possible formatting need very well, leverages Unicode CLDR, but has very limited React integration and other tools
- i18next — Good ecosystem of plugins and third party tooling, works with React well, supported from early jQuery days with minimal API changes
- Polyglot.js — Lightweight but extremely limited library, does not support any standardized formats
The decision came down to Globalize.js
and i18next
. Globalize
handles formatting very well but setup can be a pain and bundle size bloat from Unicode CLDR data negates the benefits. Most apps will also never require the advanced formatting that sets Globalize
apart. i18next
is more of a swiss army knife in that it does many things relatively well and is lightweight and intuitive to use.
Helpful i18next plugins:
- i18next-react — full React support including
<T>
component, higher order component, hook, and render prop - i18next-browser-languageDetector — detect user language from browser or previously saved cookie and LocalStorage
- i18next-parser — automate extraction of strings embedded within JavaScript code to a dedicated JSON file to submit for translations
- i18next-http-backend — load translation strings dynamically to minimize initial bundle size
- i18next-icu — support industry standard ICU JSON format for submitting and reading translations from tool vendors like Smartling. ICU JSON allows translators to specify different forms of pluralization, pair gendered nouns with the appropriate adjectives, and more advanced things of that nature.
Initial setup
In src/index.js
in Create React App or wherever your app is initialized before React is called, initialize i18next
and react-i18next
following the official documentation.
Create a file called i18nextInit.js
. I put this file under src/
. Import this file in src/index.js
so it runs when the app starts.
The initialization code is used to bootstrap plugins that can help detect language, load the desired language, and get the react-i18next
connected to i18next
.
Writing translations
Do not import the t() function directly from i18next
This sounds strange, but the t()
from i18next
operates outside of react-i18next
and React component lifecycles. Strings will not translate if called before async translations are loaded and will not update if language is changed.
React hook
This is newest and probably easiest way if you use functional components.
Official hook documentation
import { useTranslation } from 'react-i18next';const { t } = useTranslation();t('key.something', 'This is the default English version of the string')
React component
This is more versatile and self contained, especially if the existing string was passed around in utility functions that don’t have access to t()
any other way. Make sure a component can be rendered where the string was previously used.
Official component documentation
import { Trans } from 'react-i18next';<Trans i18nKey="key.something">This is the default English version of the string.</Trans>
Higher order component and render prop
Official HoC documentation
Official render prop documentation
In src/App.js
, I added an example of both but you can pick one or the other. Notice I hardcoded the number of apples in the string. We will pluralize it later. I added <React.Suspense fallback="loading">
to index.js
as well because useTranslation
can trigger suspense when the language strings are loading. You can turn this feature off if you’d like.
The translation functions rely on a set of translations for each defined locale. It will look up the translated string using the key provided in the function so these keys should all be unique. We will define the translated strings in a separate file later.
Tip 1: Use a different key for each string, even if the content of the string is the same. Take advantage of the hierarchy allowed by the .
to split keys into smaller subgroups. “okay” can be used as “confirm” in a confirmation dialog or “average” in a survey and should have different translations. Rather than sharing okay
as the key, it will be easier to manage for translators and engineers if this same string is split into something like confirmation.okay_button
and engagement_survey.okay_satisfaction_rating
.
Tip 2: Add context in the string key to reduce uncertainty in the meaning of each string. While it is possible to provide translator notes in other ways, I found it much simpler to include context in the key itself. As a translator, “Letter” with a key of print.letter
can mean a few different things, but print.letter_paper_size
makes it clear that this is a paper size and not a type of document or an individual character. It is also helpful to include information about the type of interface the string is used in such as button, tooltip, and alt tags as well.
Tip 3: Avoid using abbreviations as much as possible. “PB&J Sandwich” may not mean much to translators in other countries. Some may choose to keep the abbreviation in their translation while others will request additional context and translate that. It would be easier to write out like “peanut butter and jelly sandwich” instead.
Tip 4: Do not split up phrases or sentences. If certain portions of a phrase need to be stylized, it would be better to highlight the whole phrase instead of individual portions. <b>t("Do not split")</b> t("the sentence up like this")
,<b>t("Instead emphasize the whole thing")</b>
. Often times in other languages, syntax order is different and stylizing certain portions would be almost impossible — *Do not* the sentence *split* up like this
. Same for specifying things like Blood type: O
. It would better to allow the translator to place variable like "Blood type: {bloodType}"
instead of "Blood type: " + bloodType
so translators have the flexibility to rearrange words and numbers according to local customs.
User language and locale detection
In order to provide a good translated experience, an app should try to automatically figure out the user’s preferred language without prompting the user. The browser API window.navigator.language
is probably the easiest, most reliable, and well-supported way.
i18next-browser-languageDetector plugin takes care of reading from window.navigator.language
as well as maintaining any language selection in the browser cookie and LocalStorage. If the initially detected language is not preferred one and the user changes the language, the plugin will automatically persist that change for next time.
We need to chain the browser detection library to i18n
to let it initialize together. langDetectorOptions
is a set of optional parameters for tweaking the default behavior. For the purpose of this guide, we use en, zh, es
as our 3 languages and we need to provide this in the whitelist
has a list to pick from.
Don’t worry if your languages are actually locales with country codes like en-US
. i18next
will approximate 2 letter language codes to 4 letter locale codes like en
to en-US
and vice versa.
Tip 5: Try to create region-specific translations whenever possible if your budget allows, especially for those your app is specifically targeting. Swiss German has different idioms and syntax patterns than German Standard German. Chinese readers struggle between the simplified characters used in Mainland China and the traditional characters used in Taiwan, Singapore, and Hong Kong. Even with languages that have a lot in common such as American English and British English, users will feel much more comfortable reading their own localized language. Automated tools for these also exist.
Extracting strings for translations
We can use i18next-parser to extract the strings that are embedded in our JavaScript into a dedicated JSON file that we can then submit to Smartling or other vendors for translation.
In the root project directory, create i18next-parser.config.js
. The file output location is changed because Create React App only allows imports from within the src/
directory.
Run extraction by calling npx i18next-parser
.
This should generate a file src/locales/en.json
with the strings you’ve previously created. I took the liberty to manually create es.json
and zh.json
as well for demonstration purposes. We can then add the content of those locale files to i18next
in the initialization code. JSON webpack imports are built-in for Create React App. You may need an additional webpack plugin otherwise.
At this point, you can try viewing other languages by manually setting the locale
cookie.
Note that sometimes this extraction library doesn’t always pick up all the translations. There are some edge cases with the <Trans>
component as well as the t()
where you may need to try a different syntax or calling inside a different function or file for it to work. I don’t recommend using this parser in a fully automated script.
Async load translated strings
This is more or less a hack because i18next
doesn’t support async loading locales from webpack bundle splitting. Only recent versions of webpack support async imports.
String text can make up a significant portion of the total app bundle size, especially if many languages are involved. Loading only the required language can speed up page loading significantly.
The i18next-http-backend
is built for loading language JSON from static folders. It is not only inconvenient to copy translations files every time, but also tedious to implement cache invalidation for every update. Instead, we can leverage webpack and the import()
promise to create language bundles that can be served alongside existing JavaScript. The plugin’s async fetch behavior can be overridden to load from promises.
In localeBundles.js
, we create a list of async imports and generate a list of supported languages from those. We then write a helper function loadLocaleBundle
in i18nextInit.js
to select the right bundle to load and optionally always bundle a default fallback language. In backendOptions
, we override the default behavior of request
to load from import()
promises instead of URLs and we get the language requested by overriding the loadPath
so it could be parsed in request
easier.
You should now see an additional request being made for your selected locale.
There should really be a backend plugin that takes in a list of async imports. Please steal this idea and open a PR against i18next
!
Official HTTP backend documentation
Before anyone calls out the other million ways to split bundles by locales, I want to emphasize the simplicity of this solution. This does not require any webpack config changes or infrastructure changes necessary to serve static locale files.
Switching from one language to another
What if you don’t want a hard refresh for users when changing locales? Fortunately, react-i18next
supports dynamic language changes and updates existing translations after a new language is finished loading.
Do not import the i18n object directly from i18next
Again, i18next
falls outside of the React component lifecycle and will not trigger the appropriate updates to translate and render the changed components.
We add a button to cycle through the 3 different languages we have configured. We then add the I18nextProvider
to the root of our rendered app so it can propagate language changes to the children. The i18n
object as a method i18n.changeLanguage
to change language as well as information about current language i18n.language
.
Tip 4: If you have components or APIs inside your app that requires locale information such as third party payment widgets, make sure you get i18n.language
through one of the React methods and not directly from i18next
import. Otherwise, the third party component will not change language when requested and you end up with partially translated pages.
Pluralization
Why do I have to worry about pluralization?
Different languages have varying degrees of pluralization and different sets of rules for plurals.
English
- First form: for 1
- Second form: for all other
Arabic:
- First form: for 0
- Second form: for 1
- Third form: for 2
- Fourth form: for numbers that end with a number between 3 and 10 (like: 103, 1405, 23409).
- Fifth form: for numbers that end with a number between 11 and 99 (like: 1099, 278).
- Sixth form: for numbers above 100 ending with 0, 1 or 2 (like: 100, 232, 3001)
ICU Plural MessageFormat
I chose to use the ICU JSON format for a few reasons. Most translation vendors support this format so there is no TMS vendor lock-in. ICU JSON supports multiple forms of plural as well as variations of phrases for masculine and feminine forms of words in certain languages. These functionalities are self contained in a single string so it works with i18next-parser
out of the box.
The i18next JSON format also supports multiple forms of plural but it uses a new key for each plural form. Key additions are not an ideal solution because it is only supported by a single TMS vendor, Locize.
Since this guide is in English, I will only cover how to create the 2 English plural forms, singular and plural. The other forms for other languages should be generated by your translation vendor after strings have been translated. We will also need to provide the corresponding ICU locale data in the app for i18next-icu
format plugin so strings can be formatted properly in our app.
Simple plural example in English
You have 0 credits remaining.
You have 1 credit remaining.
You have 2 credits remaining.
t(“key.credits_remaining_warning”, { defaultValue: “You have {numCredits, plural, one {1 credit} other {{numCredits} credits}} remaining.”, numCredits: x }};
Plural example with special case for zero in English
Play this game for free.
Play this game for 1 credit.
Play this game for 2 credits.
t(“key.play_for_credits_prompt”, { defaultValue: “Play this game for {numCredits, plural, 0= {free}, one {1 credit} other {{numCredits} credits}}.”, numCredits: x }};
Multiple plurals example in English
Smartling and some other translation management software vendors do not support multiple plurals in a single string even though it is part of the ICU MessageFormat standard. We can get around this by creating 3 separate strings, 2 for each plural form, and 1 for the sentence.
Adam has 2 apples and Betty has 1 orange.
Adam has 1 apple and Betty has 2 oranges.
Adam has 2 apples and Betty has 2 oranges.
const adamsApples = t(“key.apples”, { defaultValue: “{numApples, plural, one {1 apple} other {{numApples} apples}}”, numApples}}; // 1 apple, 2 applesconst bettysOranges = t(“key.oranges”, { defaultValue: “{numOranges, plural, one {1 orange} other {{numOranges} oranges}}”, numOranges}}; // 1 orange, 2 orangest(“key.apples_and_oranges”, { defaultValue: “Adam has {adamsApples} and Betty has {bettysOranges}.”, adamsApples, bettysOranges }}; // complete sentence
Masculine and feminine forms
I’m going to skip over this one because it is easier to rewrite the phrase to avoid using pronouns altogether. Creating a system in place to save and render gender information for properly conjugating phrases is not worth the effort. In addition, gender is also not binary in certain parts of the world as well.
Gendered:
He/she needs additional time.
Gender neutral:
The customer needs additional time.
For those intellectually curious, gendered forms can be created using the select
function from ICU Message Format.
We can create another toggle button in App.js
that will cycle a variable between 0–3. In the t()
function, we can pass in an object with the translation string as defaultVariable
and other keys as replacement variables. This will give us a way to test pluralization with different quantities.
We also need to provide the ICU formatter plugin for i18next
as well as the associated formatting data for each language. The formatting data is relatively small so it is probably not worth the effort to bundle split.
ICU JSON syntax validation
The ICU JSON format is not the most straightforward format to read and write manually. i18next-icu
also crashes when there is a syntax error in the translation string. We can reduce the number of syntax errors and mistakes by using eslint-plugin-i18n-json to scan all of the translations.
I created a .eslintrc.js
file in the src/locales
directory to specifically target translation json files with root=true
to avoid inheriting the project config. I also turned off sorted-keys
because it conflicts with the i18next-parser
order and generates merge conflicts.
Run npx eslint src/locales/*.json
to validate your translation syntax.
Tip 6: In your tests or prepush
git hook, you can add the extract and validate scripts to make sure all translation changes have been extracted and that the syntax is correct.
Fallback scenarios
In the case that a translation is not available in a certain language or the additional bundles do not load for whatever reason, i18next
has a series of fallbacks the app can rely on to display something meaningful to the user.
Here’s the order of precedence for language matching as defined by the whitelist:
- Exact match of language or locale code requested:
es
toes
- Nearest match of language or locale code:
es-MX
toes
and vice versa - Fallback language defined in
init()
:fallbackLng: 'en'
The order is similar for individual translation key lookup:
- Exact or nearest match of language or locale
- Fallback language
- String provided as
defaultValue
for the translation function
Tools and processes for getting content translated
Getting the right tools and implementing a smooth process can make the overall translation much less painful. This is more important the larger your organization is. A new startup may choose to have internal staff translate the JSON files directly and avoid additional tools and vendors as well.
Translation Management System
You will need a translation management system (TMS) to process the original strings, allow translators to input their translations in different formats, check for quality, and output translations in a form engineers can use. The system will typically also save previous translations and match new ones against those as well.
I’ve only had extensive experience with Smartling. Other systems include XTM and Transifex.
Smartling Pros:
It seems like this is a popular choice among smaller tech companies. Their cost is fairly reasonable and the interface for managing translations works as intended. The software seems to be designed for non-technical people who are involved in internationalization, namely the translators and program administrators. Single sign on support and cost previews are helpful as well.
Smartling Cons:
Their developer-facing tools rarely get updated and technical support is almost nonexistent. Smartling GitHub Connector is inflexible and a pain to use. Current PRs must be kept open for translations to be added back in and there is always a risk of merge conflicts. Their API documentation leaves a lot to be desired as well.
A typical translation process for Smartling looks like this:
- Write the translation in code
- Extract to JSON file
- Commit changes and push to GitHub
- Open PR for Smartling GitHub Connector to pick up translation
- Keep PR open until Smartling GHC opens another PR against the existing PR branch to merge translations in
- Resolve any merge conflicts and then merge
Translators
Now that you have a system to manage translations, you will also need someone or something to actually translate.
- In-house translators — Product support specialists and product owners who work in other languages are great candidates since they understand your product and typically require less context. They will require training so consider the opportunity cost vs. hiring external vendors.
- Third party translators — Hiring professional third party is a good choice if your budget allows. RWS Moravia and Eriksen are two I’ve worked with in the past. It usually takes about a couple business days for strings to be translated and returned back.
- Machine translators — This is similar to using Google Translate across your app where the translation might not always make sense. There is an emerging trend to temporarily use machine translations when human translations are not yet available. Machine assisted translations exist as well where a human translator proofreads and edits machine translations.
Once your translators are added to the translation management system, you are ready to begin the process of translating your app!
What’s next?
As mentioned earlier, there are many more steps to properly internationalize and localize an app, many of which are a combination of technical and non-technical tasks. Translating an app alone will only bring limited success if the app features aren’t adapted to other markets. Take some time to review the list of topics not covered and see which ones are important to your product. This guide is already long enough so I will leave you with just a few more things to consider.
Date and time formatting
If you find yourself translating anything date or time related, even if the phrases are simply days of the week or months, consider using a library instead.
Human readable relative time — A few seconds ago
Duration — 2 hours
- moment.js — mature full feature library that can do everything, comes at a very large bundle size
- luxon.js — lightweight, relies on browser APIs, minor inconsistencies in older browsers from missing browser APIs
- date-fns — young and modular full feature library that uses browser APIs, pick and choose necessary functionality
Locale specific layout and design changes
Some languages such as German have longer words that may not fit in your English layout. A common pattern to detect these visual defects is to programmatically generate a test language that is 2x longer than the English phrase with a minimum word length of 10–15 characters and manually review each interface.
Languages in other regions of the world may not have the same typography attributes found in European languages. East Asian text is typically illegible under 12px font size on low resolution screens. Letter spacing cannot be applied to languages such as Arabic where characters are connected. Language specific customizations like these can be applied by adding thelang
to HTML elements and styling using the :lang()
CSS selector.
Fully supporting right to left language is a huge pain because users prefer having their entire layout horizontally flipped. Fortunately, there are tools like PostCSS-RTL that will programmatically generate a set of CSS styles that has all horizontal styles flipped.
Example code
As promised, here’s the final app with all the steps combined. Feel free to fork or copy. Happy translating!
Many thanks to all the people at Squarespace and WeWork who helped me learn about internationalization from the ground up. Please leave comments and suggestions.