Translating React Apps with i18next

Daniel Duan
16 min readMay 27, 2020

--

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:

  1. Why i18next
  2. Initial setup
  3. Writing translations
  4. User language and locale detection
  5. Extracting strings for translation
  6. Async load translated strings
  7. Switching from one language to another
  8. Pluralization
  9. ICU JSON syntax validation
  10. Fallback scenarios
  11. 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:

  1. Format.jsLibrary 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.
  2. Globalize.jsComprehensive library that covers every possible formatting need very well, leverages Unicode CLDR, but has very limited React integration and other tools
  3. i18nextGood ecosystem of plugins and third party tooling, works with React well, supported from early jQuery days with minimal API changes
  4. Polyglot.jsLightweight 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:

  1. i18next-reactfull React support including <T> component, higher order component, hook, and render prop
  2. i18next-browser-languageDetector detect user language from browser or previously saved cookie and LocalStorage
  3. i18next-parserautomate extraction of strings embedded within JavaScript code to a dedicated JSON file to submit for translations
  4. i18next-http-backendload translation strings dynamically to minimize initial bundle size
  5. i18next-icusupport 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.

Official documentation

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.

Official parser documentation

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:

  1. Exact match of language or locale code requested: es to es
  2. Nearest match of language or locale code: es-MX to es and vice versa
  3. Fallback language defined in init(): fallbackLng: 'en'

The order is similar for individual translation key lookup:

  1. Exact or nearest match of language or locale
  2. Fallback language
  3. 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:

  1. Write the translation in code
  2. Extract to JSON file
  3. Commit changes and push to GitHub
  4. Open PR for Smartling GitHub Connector to pick up translation
  5. Keep PR open until Smartling GHC opens another PR against the existing PR branch to merge translations in
  6. 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 translatorsThis 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.

--

--

Daniel Duan

Engineering @legitimatetech — previously @goatapp @wework @squarespace