The Ultimate Guide to React Localization with i18next

Beginner to advanced localization with React and i18next.

There is a host of internationalization (i18n) libraries when it comes to React localization. One of the most popular is i18next, and for good reason: the library is robust, feature-complete, and often maintained. i18next is also “learn once – translate everywhere”: you can use it with many front- and back-end frameworks. In this article, we walk through localizing our React apps with i18next, covering everything from library installation and setup to the ins and outs of real-world localization. Come along for the ride 😎

Library Versions

We’re using the following NPM packages (versions in parentheses) in this article:

  • React (16.13)
  • i18next (19.7)
  • react-i18next (11.7) – Official i18next hooks, components, and plugins for easy integration with React
  • i18next-http-backend (1.0) – Official back-end plugin for loading translation files over the network
  • i18next-browser-languagedetector (6.0) – Official plugin for auto-detecting the user’s language
  • Bulma (0.9) – CSS framework, for styling (in case you’re coding along)

Our Demo App: Grootbasket

To make things concrete as we’re exploring various i18n topics, we’ll build a tiny demo app that we’ll localize. The app, Grootbasket, will be a storefront for a fictional organic farm startup that sends its customers a weekly basket of organic produce. Here’s what it will look like.

Delicious and nutritious

🗒 Note » Thanks to Kiran Shastry for providing the logo we’re using for our demo app for free on the Noun Project.

🗒 Note » You can skip ahead to the i18n/l10n (section titled Installing & Setting Up i18next) if you want to. You can also start with the demo app already built and ready for i18n.

OK, let’s go through the demo app quickly so we can get to the i18n. We’ll start from scratch by spinning up a React app using Create React App.

$ npx create-react-app grootbasket

🔗 Resource » Get the complete code for the demo app from GitHub.

Let’s replace the boilerplate code with our own hot sauce.

import React from "react";
import Navbar from "./components/Navbar";
import Header from "./components/Header";
import WeeklyBasket from "./components/WeeklyBasket";
import "./App.scss";

function App() {
  return (
    <>
      <Navbar />

      <main role="main" className="pt-5 px-3">
        <Header />
        <WeeklyBasket />
      </main>
    </>
  );
}

export default App;

Our <App> component provides high-level layout and comprises a <Navbar>, a <Header> and a <WeeklyBasket>. We’ll cover the first two and skip the third, so that we can get to the i18n as quickly as possible.

<Navbar> is a relatively basic Bulma navbar that isn’t doing too much yet.

import React from "react";
import logo from "../logo.png";

function Navbar() {
  return (
    <nav
      className="navbar"
      role="navigation"
      aria-label="main navigation"
    >
      <div className="navbar-brand">
        <a className="navbar-item" href="/">
          <img className="navbar-logo" src={logo} alt="logo" />

          <strong>Grootbasket</strong>
        </a>
      </div>

      <div className="navbar-menu">
        <div className="navbar-start">
          <a className="navbar-item" href="/">
            Weekly Basket
          </a>
        </div>
      </div>
    </nav>
  );
}

export default Navbar;

Looking pretty, though

Similarly, <Header> is a presentational component that’s just waiting to be localized.

import React from "react";

class Header extends React.Component {
  render() {
    return (
      <div className="header">
        <h1 className="title is-4 has-text-centered mb-5">
          In this Week's Grootbasket — 17 Aug 2020
        </h1>

        <p>2,342 baskets delivered</p>
      </div>
    );
  }
}

export default Header;

Notice that <Header> is a class-based, not a functional, component. We’ll cover how to localize both types of components a bit later.

Our rendered header

As mentioned before, we’ll skip the <WeeklyBasket> component here. Feel free to check out its code on our GitHub repo.

And that’s about it for our starter demo app. We’re now ready to localize.

🔗 Resource » If you want to start at this point and just focus on the i18n code, you can grab a starter snapshot of the app from our GitHub repo.

Installing & Setting Up i18next

We’ll start our i18n by pulling i18next and react-i18next into our project. react-i18next is a set of components, hooks, and plugins that sit on top of i18next, and is specifically designed for React.

$ npm install --save i18next react-i18next

With the libraries installed, let’s create an i18n.js file to bootstrap an i18next instance.

import i18next from "i18next";
import { initReactI18next } from "react-i18next";

// "Inline" English and Arabic translations. 
// We can localize to any language and any number of languages.
const resources = {
  en: {
    translation: {
      app_name: "Grootbasket",
    },
  },
  ar: {
    translation: {
      app_name: "جروتباسكت",
    },
  },
};

i18next
  .use(initReactI18next)
  .init({
    resources,
    lng: "en",
    interpolation: {
      escapeValue: false,
    },
  });

export default i18next;

We use() the initReactI18next plugin provided by react-i18next. initReactI18next is responsible for binding our i18next instance to an internal store, which makes the instance available to our React components. We’ll see how we can access the i18next instance in our React components a bit later.

🔗 Resource » Check out the official documentation for a list of prebuilt i18next plugins.

We’ve also “inlined” our translations (resources), and hard-coded the active language as English (lng: "en"). We’ll see how we can load in resources asynchronously, and how to detect the user’s language from the browser, a bit later.

The interpolation config option is used to disable i18next’s escaping of values that we inject into translation messages at runtime. This is a good thing since it protects us from cross-site scripting (XSS) attacks. However, we don’t need it for React apps, since React escapes dynamic values in its components by default.

Debugging

Another handy configuration option that i18next provides is debug. We can set it to true to get console logs in our browser when certain events occur within i18next, such as initialization complete or language change.

import i18next from "i18next";

// ...

i18next
  //...
  .init({
	// ...
    debug: true,
  });

export default i18next;

i18next’s console debug output can be useful for troubleshooting

🔗 Resource » Read all about i18next’s rich configuration options in the official documentation.

To finish our setup, we just need to import our initialized i18next instance into our index.js file. This ensures that the file is bundled into our app and its code is run.

import React from "react";
import ReactDOM from "react-dom";
import "./services/i18n";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root"),
);

The t() Function & useTranslation() React Hook

That’s it for initial setup. We can now use i18next’s t() function to localize our app’s name using the translation resources we provided during setup. t() takes a string key, and returns the corresponding string from the active language’s translations.

We access t() via react-i18next’s useTranslation() react hook. The hook ensures that our components get the t() associated with our i18next instance. We’ll explore t() and useTranslations() more throughout this article.

import React from "react";
import { useTranslation } from "react-i18next";
// ...

export default function () {
  const { t } = useTranslation();

  return (
    <nav>
      <div className="navbar-brand">
        <a className="navbar-item" href="/">

          <strong>{t("app_name")}</strong>

        </a>
      </div>
    </nav>
  );
}

If we load our app now we shouldn’t notice any changes. However, our app’s name, “Grootbasket”, is now being loaded from our English resources. Let’s switch the active language to Arabic to see this in action.

import i18next from "i18next";
// ...

const resources = {
  en: {
    translation: {
      app_name: "Grootbasket",
    },
  },
  ar: {
    translation: {
      app_name: "جروتباسكت",
    },
  },
};

i18next
  // ...
  .init({
    resources,
    lng: "ar", // Active language will be Arabic
    // ...
  });

export default i18next;

When we save our i18n.js file and our app reloads, we can see our Arabic app name rendered in our navbar.

Our first localization! Hooray!

Asynchronous (Lazy) Loading of Translation Files

Of course, having a resources object in our i18n.js file doesn’t exactly scale well. As we add more translations and more languages, we’ll want to split up our translations into multiple files.

We may also want to only load the translation file(s) associated with the currently active language. This can speed up our app when we have large translation files and/or when we have many of them.

i18next provides a mechanism for this lazy loading of translation files via back-end plugins. The official i18next-http-backend plugin will do well here. Let’s install it.

$ npm install --save i18next-http-backend

Next, let’s use() the back-end plugin in our i18next instance.

import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import HttpApi from "i18next-http-backend";

i18next
  .use(initReactI18next)
  .use(HttpApi) // Registering the back-end plugin
  .init({
    // Remove resources from here
    lng: "en",
    interpolation: {
      escapeValue: false,
    },
    debug: process.env.NODE_ENV === "development",
  });

export default i18next;

Notice that we’ve also removed our resources object: we will now load our translations from the server as we need them. We’ll need to put our translation files in the location the back-end plugin expects them to be by default: public/locales/{{lng}}/{{ns}}.json—where {{lng}} is the language code, and {{ns}} is the namespace.

📖 Go deeper » Namespaces is a feature of i18next that allows for granular organization and loading of translations. They’re a bit outside the scope of this guide, and we’ll just use i18next’s default namespace for all our translations in this article. Read more about namespaces in the official i18next docs.

{
  "app_name": "Grootbasket"

 

{
  "app_name": "جروتباسكت"
}

We’re using the default, translation, namespace here.

If we were to load our app at this point, we would get an error telling us that “A React component suspended while rendering, but no fallback UI was specified.” This is because, by default, react-i18next uses React Suspense for async loading, and we’re not handling that in our code.

To fix this, let’s add a Suspense boundary around our entire <App>.

import React from "react";
import ReactDOM from "react-dom";
import "./services/i18n";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

ReactDOM.render(
  <React.StrictMode>
    <React.Suspense fallback="Loading...">
      <App />
    </React.Suspense>
  </React.StrictMode>,
  document.getElementById("root"),
);

serviceWorker.unregister();

📖 Go deeper » React Suspense is, at time of writing, an experimental feature in React that allows components to work with asynchronous data in a more optimized and natural way. Read more about Suspense in the official documentation.

With this code in place, our app will now load normally. We’ve told React to suspend rendering of the <App> component until i18next has initialized, which now depends on the first language file completing its download.

In fact, if you look at your browser’s development tools Network tab, you’ll notice a new file being downloaded.

Our active translation is now being loaded asynchronously from the network

Of course, switching the language in i18n.js to Arabic (ar), will cause the /locales/ar/translation.json file to load instead.

🔗 Resource » You can customize the location of translation files using the respective backend configuration option. Check out all of i18next-http-backend’s config options in the library’s GitHub repo readme.

✋🏽Heads up » You may have noticed that a request for locales/dev/translation.json is being made behind the scenes. This file doesn’t exist. In fact, dev is i18next’s default fallback locale. If you want to stop this request from being made, set the fallbackLng config option to be a language your app supports. In our demo app, we could set fallbackLng: "en". Alternatively, of course, you can provide the public/locales/dev/translation.json as a fallback language. We’ll cover fallback in more detail later in this article.

Getting & Setting the Active Language

We get the active language via the i18n.language property. We set it via the i18n.changeLanguage() method.

i18n.language // => "fr" when active language is French

// Changes active language to Hebrew. Components with translations
// will re-render to show Hebrew translations.
i18n.changeLanguage("he")

i18n.language // => Now "he"

Of course, this begs the question: how do we access the i18n (i18next) instance in our components?

Accessing the i18next Instance in React Components

If we’re using the react-i18next useTranslation() hook, we can just destructure the i18n object out of it.

const { i18n } = useTranslation() // i18n is the i18next instance

const { t, i18n } = useTranslation() // Also works 😉

We’ll see how to access the i18next instance when we’re not using useTranslation() later in this article.

🔗 Resource » Read the official documentation on useTranslation() to see everything you can do with the hook.

Building a Language Switcher

We often want our users to be able to change our app’s language themselves. Let’s give them a <LanguageSwitcher> component to facilitate that.

import React from "react";
import { useTranslation } from "react-i18next";

function LanguageSwitcher() {
  const { i18n } = useTranslation();

  return (
    <div className="select">
      <select
        value={i18n.language}
        onChange={(e) =>
          i18n.changeLanguage(e.target.value)
        }
      >
        <option value="en">English</option>
        <option value="ar">عربي</option>
      </select>
    </div>
  );
}

export default LanguageSwitcher;

A simple <select>, our <LanguageSwitcher> uses the i18n instance to change the active language to the one our user selects.

We can plop our <LanguageSwitcher> into our <Navbar> to reveal it in our app.

import React from "react";
import LanguageSwitcher from "./LanguageSwitcher";
// ...

function Navbar() {
  // ...

  return (
    <nav>
      {/* ... */}

      <div className="navbar-menu">
        {/* ... */}

        <div className="navbar-end">
          <div className="navbar-item">
            <LanguageSwitcher />
          </div>
        </div>
      </div>
    </nav>
  );
}

export default Navbar;

I should stop playing with this and get back to writing

✋🏽Heads up » When we switch to a language that we’ve never loaded before, i18next will asynchronously load the language’s translation file(s) from the network before switching. It will then cache the language’s files to avoid loading the file(s) again.

📖 Go deeper » We can eager load language translation files using the preload configuration option. Read more in the official documentation.

Supported Languages & Fallback

We often want to maintain a list of allowed languages that our app supports. i18next provides a way to set and get supported languages through config options.

// At initialization
i18next
  .init({
    lng: "en",

    // Allowed languages
    supportedLngs: ["en", "ar"],
  });

// In our components
const { i18n } = useTranslation(); 

i18n.options.supportedLngs // => ["en", "ar", "cimode"]

✋🏽Heads up » The “cimode” element present when we read the supportedLngs array is meant to be used for end-to-end (e2e) tests.

supportedLngs only affects what language translations can be loaded. Our app’s active language, set via the lng config option or i18n.changeLanguage(), can be anything we want regardless of the set supportedLngs. This may be best demonstrated with an example.

const resources = {
  en: {
    translation: {
      app_name: "Grootbasket",
    },
  },
  es: {
    translation: {
      app_name: "Grootcesta",
    },
  },
  ar: {
    translation: {
      app_name: "جروتباسكت",
    },
  },
}

i18next.init({
  resources,
  lng: "es", // Set lng to unsupported language
  supportedLngs: ["en", "ar"]
})

i18next.language // => "es", was allowed

// Spanish (es) translations were lot loaded, so i18next will not find
// the Spanish message
i18next.t("app_name") // => "app_name"

Notice that while we were able to change the active language to Spanish (es) above, its translations were never loaded from the resources object, because "es" is not in our supportedLngs.

If we were using a back-end to asynchronously load our translations in the example above, the Spanish (es) translations would never get loaded from the network.

Fallback

What happens when i18next does not find a translation message for a given key? This might happen because the active language is not in our supportedLngs array, or because we forgot to provide the message in the active language’s translations. As we’ve seen earlier, i18next will return the key of the message in this case.

const resources = {
  en: {
    translation: {
      app_name: "Grootbasket",
      signup_button: "Sign up",
    },
  },
  ar: {
    translation: {
      app_name: "جروتباسكت",
    },
  },
}

i18next.init({
  resources,
  lng: "ar",
  supportedLngs: ["en", "ar"]
})

// We forgot to provide this message in Arabic (ar)
i18next.t("signup_button") // => "signup_button", key is returned

However, we can explicitly set a fallback language for i18next to use when it cannot find a message in the active language. We use the fallbackLng config option for this.

const resources = {
  en: {
    translation: {
      app_name: "Grootbasket",
      signup_button: "Sign up",
    },
  },
  ar: {
    translation: {
      app_name: "جروتباسكت",
    },
  },
  es: {
    translation: {
      app_name: "Grootcesta",
      signup_button: "Registrarse",
    },
  },
}

i18next.init({
  resources,
  lng: "ar",
  supportedLngs: ["en", "ar"],
  fallbackLng: "en",
})

// Message not found in Arabic (ar), will use English (en) as fallback
i18next.t("signup_button") // => "Sign up"

i18next.changeLanguage("es") 

i18next.language // => es

// Spanish (es) is not supported, so its translations were not loaded
// Will use English (en) as fallback
i18next.t("signup_button") // => "Sign up"

In case you’re coding along with us, here’s the i18n.js file in our demo app at this point.

import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import HttpApi from "i18next-http-backend";

i18next
  .use(initReactI18next)
  .use(HttpApi)
  .init({
    lng: "en",
    supportedLngs: ["en", "ar"],

    // Allows "en-US" and "en-UK" to be implcitly supported when "en"
    // is supported
    nonExplicitSupportedLngs: true,

    fallbackLng: "en",
    interpolation: {
      escapeValue: false,
    },
    debug: process.env.NODE_ENV === "development",
  });

export default i18next;

Automatically Detecting the User’s Language

To provide the best UX we can, it’s often a good idea to see what language the user prefers—whether through her browser preferences or her previous visit to our app—and to show her the language closest to that. i18next provides an official browser detection plugin that we can use to make this job a lot easier. Let’s install it.

$ npm install --save i18next-browser-languagedetector

We’ll need to use() it in our i18next instance. We’ll also need to remove the lng config option, otherwise it will override any auto-detection.

import i18next from "i18next";
import { initReactI18next } from "react-i18next";
import HttpApi from "i18next-http-backend";
import LanguageDetector from "i18next-browser-languagedetector";

i18next
  .use(initReactI18next)
  .use(HttpApi)
  .use(LanguageDetector) // Registering the detection plugin
  .init({
	// ...

	// Remove the lng option from here

    // ...
  });

export default i18next;

That’s basically all we need to start detecting the user’s preferred language. Here’s what the detector will do by default:

  1. Attempt to find a ?lng=en query string parameter in the request URL. If this fails,
  2. Attempt to find a domain cookie called "i18next" with a stored language. If this fails,
  3. Attempt to find an entry in the domain’s localStorage called "i18nextLng" with a stored language. If this fails,
  4. Attempt to find an entry in session storage called "i18nextLng" with a stored language. If this fails,
  5. Attempt to determine the user’s first preferred language from her browser settings (navigator object). If this fails,
  6. Attempt to determine the language from the lang attribute of the page’s <html> tag.

At this point, a language should have been detected. The detected language will get saved in a cookie, localStorage, or session storage, so that the user will see this language on her next visit to our site.

✋🏽Heads up » It’s a good idea to have a fallback language configured in case no language is detected. It might also be a good idea to set nonExplicitSupportedLngs: true, so that an ar-EG (Egyptian Arabic) user will see the ar (Arabic) version of our app. See the Supported Languages & Fallback section above for more info on fallbacks.

✋🏽Heads up » The detected language does not need to be a supported language ( in supportedLngs), and will become the active language ie. i18n.language . Be aware of this when writing code that relies on i18n.language.

If the active language is changed manually via i18n.changeLanguage(code), the detector will store this language for the user’s next visit.

📖 Go deeper » A lot of i18next-browser-languagedetector’s behaviour is configurable. Check out the official documentation for all the juicy details.

The withTranslation High Order Component (HOC)

If you’re anything like me, you’ll likely use the useTranslation() hook for most of your components. There are times, however, where the alternative withTranslation HOC comes in handy.

🗒 Note » If you’ve been working with React for a bit, you’ll know that a HOC, or high order component, is a component function that takes a component parameter and wraps it, returning another component.

import React from "react";
import { withTranslation } from "react-i18next";

class Header extends React.Component {
  render() {
    return (
      <div className="header">
        <h1 className="...">
          {this.props.t("weekly_basket_title")}
        </h1>

		{/* ... */}
      </div>
    );
  }
}

// Here's where the magic happens
export default withTranslation()(Header);

We’ve used the withTranslation HOC to wrap our <Header> component. Notice that our <Header> is not a functional component, it’s class-based. So we couldn’t use the useTranslation() hook to access i18next’s t() function as usual here.

Instead, withTranslation makes a this.props.t() available to our <Header>. This t() works exactly the same way as useTranslation’s.

🗒 Note » We also have the i18next instance available in <Header> via this.props.i18n. We’re not using it in the code above, but it’s there if we need it.

🔗 Resource » Read the official documentation on the withTranslation HOC.

The Translation Render Prop

Alternatively, we can use the <Translation> render prop to access t() in our components.

🗒 Note » Another React pattern, a render prop is a component with a function prop, where the function handles the rendering instead of the component itself.

import React from "react";
import { Translation } from "react-i18next";

class Header extends React.Component {
  render() {
    return (
      <Translation>
        {(t) => (
          <div className="header">
            <h1 className="...">
              {t("weekly_basket_title")}
            </h1>

            {/* ... */}
          </div>
        )}
      </Translation>
    );
  }
}

export default Header;

As with the withTranslation HOC, <Translation>’s t() is exactly the same as the one useTranslation() provides.

📖 Go deeper » The i18next instance can be accessed within <Translation> as well. Find out how in the official documentation on the Translation render prop.

Basic Translation Messages

We’ve covered basic translation messages in previous sections, so we’ll go over them quickly for completeness.

// In our translation resources
{
  en: {
    translation: {
      "weekly_basket": "Weekly Basket"
    }
  },
  es: {
    translation: {
      "weekly_basket": "Cesta Semanal"
    }
  }
}

// In our components
const { t, i18n } = useTranslation()

i18n.changeLanguage("en")

<p>{t("weekly_basket")}</p> // => <p>Weekly Basket</p>

i18n.changeLanguage("es")

<p>{t("weekly_basket")}</p> // => <p>Cesta Semanal</p>

Interpolation

We often want to inject values into our translations messages at runtime. Let’s demonstrate this by adding a greeting to “logged-in” users to our app.

 {
   // ...
   "hello_user": "Hello, {{user}}"
 }
{
  // ...
  "hello_user": "أهلاً {{user}}"
}
import React from "react";
import { useTranslation } from "react-i18next";
// ...

function Navbar({ onLanguageChange }) {
  const { t } = useTranslation();

  return (
    <nav>
      {/* ... */}

      <div className="navbar-menu">
        {/* ... */}

        <div className="navbar-end">
          <div className="navbar-item">
            
            {/* Mock logged in user */}
            <p>{t("hello_user", { user: "Abed" })}</p>
          </div>

          {/* ... */}
        </div>
      </div>
    </nav>
  );
}

export default Navbar;

By default, i18next allows us to use the {{placeholder}} syntax in our translation messages for interpolation. We then need to supply a second argument to t(): a simple map of {placholder: value}. We can have as many placeholders, and respective entries in our map as we want.

Personalized greetings always brighten up my day

Data Models as Interpolated Values

We can also use data models, name/value objects, for interpolation. Let’s refactor the code above to use a data model.

 {
   // ...
   "hello_user": "Hello, {{user.firstName}}",
 }
 {
  // ...
  "hello_user": "أهلاً {{user.firstName}}",
 }
import React from "react";
import { useTranslation } from "react-i18next";
// ...

function Navbar({ onLanguageChange }) {
  const { t } = useTranslation();

  const user = { firstName: "Abed", lastName: "Nadir" };

  return (
    <nav>
      {/* ... */}

      <div className="navbar-menu">
        {/* ... */}

        <div className="navbar-end">
          <div className="navbar-item">
            <p>{t("hello_user", { user })}</p>
          </div>

          {/* ... */}
        </div>
      </div>
    </nav>
  );
}

export default Navbar;

We pass the entire user object to t() and refine into the values we want in our translation messages using dots, e.g. user.firstName.

🔗 Resource » i18next interpolation is extremely configurable. You can change the characters used for placeholders ({{ and }} by default), as well as provide a formatting function for custom interpolation logic. We’ll make use of the formatting function option when we write our own date and number formatting a bit later. Check out the official documentation for all of the interpolation options available.

📖 Go deeper » Although often unnecessary, i18next does allow nesting of messages in other messages.

Plurals

i18next gives us the ability to define both simple (single/plural) and complex (multiple form) pluralization in our translation messages.

Simple Plurals

Remember our hard-coded “baskets delivered” message?

Many happy customers

// ...

class Header extends React.Component {
  render() {
    return (
        {/* ... */}
          <div>
            {/* ... */}

            <p>2,342 baskets delivered</p>
          </div>
        {/* ... */}
    );
  }
}

// ...

Let’s localize this message and make sure it respects English plural rules.

{
  // ...
  "basket_delivered": "{{count}} basket delivered",
  "basket_delivered_plural": "{{count}} baskets delivered"
}
{
  // ...
  // This is incorrect Arabic pluralization. We'll fix it later.
  "basket_delivered": "{{count}} سبت تم توصيله",
  "basket_delivered_plural": "{{count}} سبت تم توصيله"
}

Notice how we provided two messages: key and key_plural. We can refer to the key when calling t().

// ...

class Header extends React.Component {
  render() {
    return (
      {/* ... */}
          <div>
            {/* ... */}

            <p>{t("basket_delivered", { count: 2342 })}</p>
          </div>
        )}
      {/* ... */}
    );
  }
}

// ...

When we provide basket_delivered , and the special value count to t(), i18next automatically selects the appropriate plural form from basket_delivered and basket_delivered_plural.

i18next displays the correct plural form automatically

✋🏽Heads up » The counter variable must be called count, otherwise plural selection won’t work.

📖 Go deeper » i18next also provides generalized selection, which can be used for male/female messages, for example. This is called context, and while it’s outside the scope of this guide, you can check it out in the official docs.

Multiple Plurals

While the singular/plural forms work for English, they don’t work for many other languages. A quick glance at the Unicode CLDR Language Plural Rules table reveals that languages vary in the number of plural categories, or forms, they have. So i18next’s key and key_plural variants aren’t enough to cover Arabic, for example, which often has five plural forms, depending on the word.

i18next allows us to handle multiple plural forms in our translation messages using key_0, key_1, etc., to map messages to plural forms. Let’s demonstrate this by translating our “baskets delivered” message into Arabic.

{
  "basket_delivered_0": "لم يتم توصيل سلال",
  "basket_delivered_1": "تم توصيل سله {{count}}",
  "basket_delivered_2": "تم توصيل سلتان",
  "basket_delivered_3": "تم توصيل {{count}} سلال",
  "basket_delivered_4": "تم توصيل {{count}} سله",
  "basket_delivered_5": "تم توصيل {{count}} سله"
}

Our call to t() can stay the same:

<p>{t("basket_delivered", { count: 2342 })}</p>

i18next will automatically match the correct Arabic plural form based on the given count.

baskets_delivered_0 → count = 0
baskets_delivered_1 → count = 1
baskets_delivered_2 → count = 2
baskets_delivered_3 → count is between 3 and 10, inclusive
baskets_delivered_4 → count is between 11 and 100, inclusive
baskets_delivered_5 → count > 100

Our message is always presented in the correct Arabic plural form

🔗 Resource » Of course, you’re not expected to just know the key_0, key_1 mappings to a language’s plural forms. In fact, there’s a nice utility on JSFiddle that does that for you.

✋🏽Heads up » If you don’t provide a plural form for a language, i18next will use your fallback language for that message. See the Supported Languages & Fallback section above for more details on fallback.

HTML in Translation Messages with the Trans Component

On occasion, we will have HTML within our translation messages. This can be a bit tricky to deal with. It’s tempting to just plop the HTML in our translation resources and use React’s dangerouslySetInnerHTML prop to bypass escaping. The prop gets its name for a reason, however: using it can expose our site to XSS attacks.

This is why react-i18next provides a <Trans> component that allows us to keep translation message HTML in our components, where we can control it. Let’s see this in action. We’ll add a footer with links to our app, and use <Trans> to localize it.

import React from "react";
import { Trans } from "react-i18next";

function Footer() {
  return (
    <footer className="footer">
      <p className="has-text-centered">
        <Trans i18nKey="footer">
          Demo for a{" "}
          <a href="https://phrase.com/blog">Phrase blog</a>{" "}
          article.
          <br />
          Created with React, i18next, and Bulma.
        </Trans>
      </p>
    </footer>
  );
}

export default Footer;

Notice the i18nKey prop on the <Trans> component. i18nKey is exactly the same key we would give to the t() function to reference a translation message. What the <Trans> component allows us to do is mark parts of our messages where we want to inject HTML.

{
  // ...
  "footer": "Demo for <2>Phrase blog</2> article.<5/>Created with React, i18next, and Bulma."
}
{
  // ...
  "footer": "عرض لمقالة في <2>مدونة فرايز</2>.<5/>بني بواسطة ريأكت و أي إيتين نكست و بولما."
}

In our translation messages, we designate where we want to inject HTML based on the element/component order that we’ve passed to <Trans>.

<Trans i18nKey="footer">
  Demo for a                                         // => 0
  {" "}                                              // => 1
  <a href="https://phrase.com/blog">Phrase blog</a>  // => 2
  {" "}                                              // => 3
  article.                                           // => 4
  <br />                                             // => 5
  Created with React, i18next, and Bulma.            // => 6
</Trans>

Notice how the <2>...</2> and <5/> tags in our messages correspond to locations where we want to inject HTML tags when we render the messages.

🔗 Resource » Read the official documentation on the Trans component.

Layout Direction — Left-to-Right & Right-to-Left

When it comes to layout direction, the i18next instance provides a dir() function that returns the direction of the current language: "ltr" for left-to-right languages, and "rtl" for right-to-left languages.

i18n.changeLanguage("en")

i18n.dir() // => "ltr"

i18n.changeLanguage("ar")

i18n.dir() // => "rtl"

Let’s use this function to set the direction of the HTML page in our <App>.

import React, { useEffect } from "react";
import { useTranslation } from "react-i18next";
// ...

function App() {
  const { i18n } = useTranslation();

  useEffect(() => {
    document.dir = i18n.dir();
  }, [i18n, i18n.language]);

  return (
    {/* ... */}
  );
}

export default App;

The React useEffect hook comes in handy here. We use it to update the document.dir after first render, and whenever the active language changes in our i18next instance. document.dir maps to the <html dir="..."> attribute.

🔗 Resource » Check out the official React documentation on the effect hook.

Now, whichever language our app’s visitor selects, she will be presented with the correct layout direction for that language.

Right then, no direction left out

Date Formatting

i18next doesn’t provide any built-in date formatting. Instead, it accepts an interpolation.format function configuration option. When we provide a custom interpolation.format, we’re free to use any date formatting logic we want. Let’s see this in action by formatting the date in the “Weekly basket” header in our app.

import React from "react";
import { Translation } from "react-i18next";

class Header extends React.Component {
  render() {
    return (
      <Translation>
        {(t) => (
          <div>
            <h1>
              {t("weekly_basket_title", {
                today: new Date(),
              })}
            </h1>

            {/* ... */}
          </div>
        )}
      </Translation>
    );
  }
}

export default Header;
{
  // ...
  "weekly_basket_title": "In this Week's Grootbasket — {{today, date}}",
  // ...
}
{
  // ...
  "weekly_basket_title": "في الجروتباسكت هذا الإسبوع — {{today, date}}",
  // ...
}
import i18next from "i18next";
// ...

i18next
  // ...
  .init({
    // ...

    interpolation: {
      // ...

      // value == today from {{today, date}} == new Date()
      // format == date from {{today, date}} == "date"
      // lng == active language
      format: function (value, format, lng) {
        if (format === "date") {
          return new Intl.DateTimeFormat(lng).format(value);
        }

        return value;
      },
    },

    // ...
  });

// ...

✋🏽Heads up » Our custom interpolation.format function is only called for interpolated values with explicit formats. {{today}} wouldn’t work: it would use i18next’s default interpolation. {{today, foo}} does work.

The value parameter is the value of today, which we set to new Date(), a datetime object corresponding to the present date and time.

format is the second argument in our {{today, date}} and is passed as a string, "date".

lng is the active language in our i18next instance.

Whatever we return from interpolation.format() will be injected into our translation message. In the code above, we’re using the standard JavaScript Intl.DateTimeFormat constructor to format our date. We could use any date formatting library here, or roll our own. Intl.DateTimeFormat seems to be doing a good job for our needs.

Intl.DateTimeFormat even uses Eastern Arabic numerals for Arabic dates

Customizing the Date Format

Of course, we often need to present dates in different ways on a case-by-case basis. In some places, we might want “2/11/2020”, in others “Nov 2, 2020”. The Intl.DateTimeFormat constructor gives us a few formatting options to accomplish this. Let’s expose these options in our translation messages.

🔗 Resource » Check out the MDN docs for Intl.DateTimeFormat for all available formatting options, and their current browser support.

First, let’s move our interpolation.format logic into its own module and refactor it since we’re about to expand it a bit.

function format(value, format, lng) {
  if (format === "date") {
    return formatDate(value, format, lng);
  }

  return value;
}

function formatDate(value, format, lng) {
  return new Intl.DateTimeFormat(lng).format(value);
}

export default format;
//...
import format from "./i18n-format";
// ...

i18next
  // ...
  .init({
    // ...

    interpolation: {
      // ...
      format,
    },

    // ...
  });

// ...

With this refactor we can keep our custom formatting work in the i18n-format.js file. Now let’s get to customizing. We can manipulate our date formatting by passing an options map when we construct our Intl.DateTimeFormat formatter.

const options = {
  day: "numeric",
  month: "short",
  year: "numeric"
};

new Intl.DateTimeFormat("en", options).format(new Date("2020-11-05"));
// => "Nov 5, 2020"

🔗 Resource » All Intl.DateTimeFormat options are documented on the Intl.DateTimeFormat MDN page.

These options can be exposed to our messages through a custom syntax for our format strings.

{
 "weekly_basket_title": "In this Week's Grootbasket — {{date, date(day: numeric; month: short; year: numeric)}}",
}

Notice how our formatting options map exactly to the ones supported by Intl.DateTimeFormat. Let’s get this working.

function format(value, format, lng) {
  if (format.startsWith("date")) {
    return formatDate(value, format, lng);
  }

  return value;
}

function formatDate(value, format, lng) {
  const options = toOptions(format);

  return options === null
    ? value
    : new Intl.DateTimeFormat(lng, options).format(value);
}

function toOptions(format) {
  // Handle case with no options, e.g. {{today, date}}
  if (format.trim() === "date") {
    return {};
  } else {
    try {
      return JSON.parse(toJsonString(format));
    } catch (error) {
      console.error(error);

      return null;
    }
  }
}

function toJsonString(format) {
  const inner = format
    .trim()
    .replace("date", "")
    .replace("(", "")
    .replace(")", "")
    .split(";")
    .map((param) =>
      param
        .split(":")
        .map((name) => '"' + name.trim() + '"')
        .join(":"),
    )
    .join(",");

  return "{" + inner + "}";
}

export default format;

We’re basically converting date(day: numeric; month: short; year: numeric) to {"day":"numeric","month":"short","year":"numeric"}, which is a JSON string that can be parsed into a JavaScript object. This object is exactly what we need to pass as the options parameter to Intl.DateTimeFormat. We can now customize the look of each date in our message on a case-by-case basis.

{
 "weekly_basket_title": "In this Week's Grootbasket — {{date, date(day: numeric; month: short; year: numeric)}}"
}
{
  "weekly_basket_title": "في الجروتباسكت هذا الإسبوع — {{date, date(weekday: short; day: numeric; month: long)}}"
}

Total power over date formats. Use wisely.

🔗 Resource » If you want to use Moment instead of Intl.DateTimeFormat, our article Beginning JavaScript I18n with i18next and Moment.js might be just what you’re looking for.

Number Formatting

We’re not currently localizing the numbers in our app, which isn’t ideal; not all languages use Western Arabic numerals (1, 2, 3).

We’re incorrectly using Western Arabic numerals for Arabic

Arabic actually uses Eastern Arabic numerals (١، ٢، ٣). In fact, different languages use different numerals, currency symbols, separator characters, and separator placement.

📖 Go deeper » We cover the nuances of number localization in our article A Concise Guide to Number Localization.

We laid the groundwork for number localization when we took care of date formatting. All we have to do is extend our custom format() function to handle number formatting as well.

In a similar vein to our date formatting solution, we’ll use the standard JavaScript Intl.NumberFormat constructor. And again, we’ll allow for default and customizable number formatting in our translation messages.

{
  // Default number formatting
  "basket_delivered": "{{count, number}} basket delivered",
  "basket_delivered_plural": "{{count, number}} baskets delivered",

  // Customized number formatting
  "estimated_weight_kg": "{{weight, number(style: unit; unit: kilogram)}}"
}

The custom formatting options will get passed to Intl.NumberFormat, and we’ll handle them just like we did with date formatting.

🔗 Resource » Intl.NumberFormat also handles currency formatting, among many other options. Get the details from the MDN Intl.Numberformat docs.

function format(value, format, lng) {
  if (format.startsWith("date")) {
    return formatDate(value, format, lng);
  }

  if (format.startsWith("number")) {
    return formatNumber(value, format, lng);
  }

  return value;
}

function formatDate(value, format, lng) {
  const options = toOptions(format, "date");

  return options === null
    ? value
    : new Intl.DateTimeFormat(lng, options).format(value);
}

function formatNumber(value, format, lng) {
  const options = toOptions(format, "number");

  return options === null
    ? value
    : new Intl.NumberFormat(lng, options).format(value);
}

function toOptions(format, specifier) {
  if (format.trim() === specifier) {
    return {};
  } else {
    try {
      return JSON.parse(toJsonString(format, specifier));
    } catch (error) {
      console.error(error);

      return null;
    }
  }
}

function toJsonString(format, specifier) {
  const inner = format
    .trim()
    .replace(specifier, "")
    .replace("(", "")
    .replace(")", "")
    .split(";")
    .map((param) =>
      param
        .split(":")
        .map((name) => `"${name.trim()}"`)
        .join(":"),
    )
    .join(",");

  return "{" + inner + "}";
}

export default format;

We’ve updated our toOptions() and toJsonString() functions to take specifier parameters. A format specifier can be "date" or "number". Otherwise, everything is exactly the same as our date formatting. We’re just using Intl.NumberFormat instead of Intl.DateFormat.

{
  // ...
  "estimated_weight_kg": "{{weight, number(style: unit; unit: kilogram)}}",

  "basket_delivered": "{{count, number}} basket delivered",
  // ...
}
{
  // ...
  "estimated_weight_kg": "{{weight, number(style: unit; unit: kilogram)}}",

  // ...

  "basket_delivered_1": "تم توصيل سله {{count, number}}",
  // ...
}
import React from "react";
import { Translation } from "react-i18next";

class Header extends React.Component {
  render() {
    return (
      <Translation>
        {(t) => (
          <div>
            {/* ... */}

            <p>{t("basket_delivered", { count: 2342 })}</p>
          </div>
        )}
      </Translation>
    );
  }
}

export default Header;
import React from "react";
import { useTranslation } from "react-i18next";

function Item({
  // ...
  estimatedWeightInKilograms,
}) {
  const { t } = useTranslation();

  return (
    <div>
      {/* ... */}

            <p>
              {/* ... */}

              {t("estimated_weight_kg", {
                weight: estimatedWeightInKilograms,
              })}
            </p>
    </div>
  );
}

export default Item;

By delegating our localized number formatting to Intl.NumberFormat, we get the correct numerals, separators, and units for the active language.

Right numbers for the right language

🔗 Resource » If you’re localizing units, like we did above with kilograms, you might want to visit the ECMAScript Internationalization API Specification, which lists the units that you can use with Intl.NumberFormat.

With that in place, our saucy demo app looks all saucy now.

Get your weekly organic basket in any language

🔗 Resource » Get the complete code for the demo app from GitHub.

Toodles

We hope that you’ve learned a few new things when it comes to React localization with i18next.

And if you want to take your i18n game to the next level, check out Phrase. Phrase is a professional i18n/l10n solution built by developers for developers. Featuring a robust CLI and API, GitHub, Bitbucket, and GitLab sync, machine learning translations, and a great web console for your translators, Phrase will do the heavy lifting in your i18n/l10n process to keep you focused on the creative code you love. Not only that, Phrase has an In-Context Editor that works beautifully with i18next. Check out all of Phrase’s features, and sign up for a free 14-day trial.

4.7 (93.33%) 120 votes
Comments
close

Automate Your Localization Workflow for Continuous Deployment

Automate Localization for Continuous Deployment

  • Integrate Phrase into your agile environment easily
  • Import and export your localization files in any format
  • Automate your localization workflow to speed up every release