Software localization

A Guide to React Localization with i18next

React-i18next is a powerful set of components, hooks, and plugins that sit on top of i18next. Learn how to use it to internationalize your React apps.
Software localization blog category featured image | Phrase

React is so ubiquitous today that it might as well be a standard for building web apps (and myriad other UI). But part of React’s success is its hyper-focus on component-based, reactive UI: To build complete apps, we often have to compose our own frameworks around React. So what happens when your React app needs to speak other languages? If you’re here, you might be thinking about the most popular internationalization library, i18next. It’s a wise choice: In addition to its popularity, i18next is mature and extensible. Any internationalization problem you can think of is probably already solved with i18next or one of its many plugins.

In this tutorial, we’ll explore how to leverage i18next with React to create dynamic, multilingual applications. We’ll cover everything from basic setup to advanced features, ensuring your React app is speaking multiple languages in no time.

Internationalization and localization

Internationalization (i18n) and localization (l10n) allow us to make our apps available in different languages and to different regions, often for more profit. If you’re new to i18n and l10n, check out our guide to internationalization.

Prerequisites and package versions

We assume you’re familiar with React development, and other than that we try to explain the i18n code as clearly as possible.

If you want to run the demo app on your local machine (you don’t have to), you’ll need a fairly recent version of Node.js. Speaking of which, here are the packages we’re using in this guide (with versions at the time of writing):

  • vite (5.0) — Our super fast module bundler (you can use create-react-app / Webpack or whatever you like).
  • typescript (5.3) — We’ll write in TypeScript, but you don’t have to. Use plain JavaScript if you want.
  • react (18.2)
  • i18next (23.7) — Our i18n library.
  • i18next-http-backend (2.4) — Loads translation files.
  • i18next-browser-languagedetector (7.2) — Detects the user’s locale (language).
  • tailwindcss (3.4) — Used for styling; largely optional here.

The demo app: a React and i18next playground

We’ve prepared an interactive demo as a companion to this article that you can access directly on StackBlitz. Alternatively, you can grab the project code from GitHub and run it on your machine; just make sure you have a recent version of Node.js installed.

Our demo app in the default language, English.
Our demo app in the default language, English.
Our demo app localized to Arabic.
Our demo app localized to Arabic.

📣 Shoutout » Thanks to rawpixel.com for providing their Grid Background for free on Freepik, which we’re using in our demo app.

How do I install and configure i18next for my React app?

Installation is with NPM (or your preferred equivalent), of course.

npm install react-i18next i18nextCode language: Bash (bash)

i18next is the core library, but the i18next team also provides an official extension for React, react-i18next. With both packages installed, we get a custom React hook and components that allow us to work with i18next quickly and easily in our React projects.

Let’s configure these libraries and wire them up to our React app. We’ll create a new directory, src/i18n, and put a config file in there.

// src/i18n/config.ts

// Core i18next library.
import i18n from "i18next";                      
// Bindings for React: allow components to
// re-render when language changes.
import { initReactI18next } from "react-i18next";

i18n
  // Add React bindings as a plugin.
  .use(initReactI18next)
  // Initialize the i18next instance.
  .init({
    // Config options

    // Specifies the default language (locale) used
    // when a user visits our site for the first time.
    // We use English here, but feel free to use
    // whichever locale you want.                   
    lng: "en",

    // Fallback locale used when a translation is
    // missing in the active locale. Again, use your
    // preferred locale here. 
    fallbackLng: "en",

    // Enables useful output in the browser’s
    // dev console.
    debug: true,

    // Normally, we want `escapeValue: true` as it
    // ensures that i18next escapes any code in
    // translation messages, safeguarding against
    // XSS (cross-site scripting) attacks. However,
    // React does this escaping itself, so we turn 
    // it off in i18next.
    interpolation: {
      escapeValue: false,
    },

    // Translation messages. Add any languages
    // you want here.
    resources: {
      // English
      en: {
        // `translation` is the default namespace.
        // More details about namespaces shortly.
        translation: {
          hello_world: "Hello, World!",
        },
      },
      // Arabic
      ar: {
        translation: {
          hello_world: "مرحباً بالعالم!",
        },
      },
    },
  });

export default i18n;Code language: TypeScript (typescript)

We initialize and configure an i18next instance for our basic setup. i18next is highly configurable, so check out the Configuration Options docs to tweak to your heart’s content.

Alright, let’s import this file into our app’s entry point to get it wired up.

// src/main.tsx

// 👆 This might be index.tsx or
// index.js in your app.

  import React from "react";
  import ReactDOM from "react-dom/client";
  import App from "./App.tsx";
+ import "./i18n/config.ts";
  import "./index.css";

  ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>,
  );Code language: Diff (diff)

When we run our app now, we should see some output in our browser’s dev console telling us that i18next has initialized correctly. This is thanks to the debug: true option we added to our config.

Browser dev tools console output showing that i18n is initializing.

A quick test: Translating a component

So how does this all work in a React component? Let’s give it a go.

// src/App.tsx

// React hook that ensures components are
// re-rendered when locale changes.
import { useTranslation } from "react-i18next";

function App() {
  // The `t()` function gives us
  // access to the active locale's
  // translations.
  const { t } = useTranslation();

  return (
    <div className="...">
      {/* We pass the key we provided under
          `resources.translation` in 
          src/i18n/config.ts */}
      <h2>{t("hello_world")}</h2>
    </div>
  );
}

export default App;Code language: TypeScript (typescript)

If we load our app now, we should see our English “Hello, World!” message.

An English translation message.

And if we change the default locale to Arabic, our component re-renders and shows us the Arabic hello_world translation.

The same message, translated to Arabic.

Namespaces

You may have noticed the translation namespace under resources when we configured i18next. Namespaces are effectively groups, and i18next uses them to allow splitting translations into logical collections for bigger apps (e.g. admin, public).

This can make apps more performant when each namespace houses its translations into a separate file, and a namespace’s file is only loaded when needed, ideally asynchronously. (We’ll cover async file loading shortly).

translation is the default namespace used by i18next, and it’s the only one we’ll be using in this article. Feel free to check out the Namespaces docs page if you want to dive deeper here.

Locale codes (en, ar, etc.)

A locale defines a language, a region, and sometimes more. Locales typically use IETF BCP 47 language tags, like en for English, fr for French, and es for Spanish. Adding a region with the ISO Alpha-2 code (e.g., BH for Bahrain, CN for China, US for the United States) is recommended for accurate date and number localization. So a complete locale might look like en-US for American English or zh-CN for Chinese as used in China.

🔗 Explore more language tags on Wikipedia and find country codes through the ISO’s search tool.

✋ i18next’s locale resolution tends to work best when we have language-only locales, like en and ar. We stick to those in this article, but be aware that when we localized dates and numbers, the browser is making the choice of region for formatting. Given ar, one browser might choose to format dates for Arabic-Egypt (ar-EG) where another chooses Saudi Arabia (ar-SA). We solve this issue a bit later in this article when we write our own custom number and date formatters.

How do I load translation files asynchronously?

We’re currently inlining translations into our i18n config file.

// src/i18n/config.ts
import i18n from "i18next";
// ...

i18n
  // ...
  .init({
    //...
    resources: {
      en: {
        translation: {
          hello_world: "Hello, World!",
        },
      },
      ar: {
        translation: {
          hello_world: "مرحباً بالعالم!",
        },
      },
    },
  });

export default i18n;Code language: TypeScript (typescript)

This can work well for a couple of languages and a handful of translation messages, but as you can imagine this solution doesn’t scale very well. As more languages and translations are added, our config file would get bloated, slowing down our initial app load.

Moreover, we often want to hand off a single language file to a translator, and this approach doesn’t accommodate that very well.

Let’s split our translations into separate files, one per locale. While we’re at it, we’ll ensure that the active locale’s translation file will load asynchronously from the network. This will speed up our app as it scales and allow us to provide each translator only the file(s) of their language.

First, let’s install the official i18next HTTP API backend plugin. It’s the simplest way to download translation files from the network and connect them with i18next.

npm install i18next-http-backendCode language: Bash (bash)

Next, we’ll configure the backend.

// src/i18n/config.ts

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

  i18n
+   // Wire up the backend as a plugin.
+   .use(HttpApi)
    .use(initReactI18next)
    .init({
      lng: "en",
      fallbackLng: "en",
      debug: true,
      interpolation: {
        escapeValue: false,
      },
-     // Remove the inlined translations.
-     resources: {
-       en: {
-         translation: {
-           hello_world: "Hello, World!",
-         },
-       },
-       ar: {
-         translation: {
-           hello_world: "مرحباً بالعالم!",
-         },
-       },
-     },
    });

  export default i18n;Code language: Diff (diff)

Now that we’ve removed the inlined translations, we better add them in separate files. By default, the HTTP API backend will look for a translation file at a given URL when i18next initializes. If our active locale is English, it will look for the file at a public URL relative to the root of our website: http://example.com/locales/en/translation.json

🗒️ Remember, translation is the default namespace.

Let’s add our files where the backend will expect them, placing them under the public directory so that they’re available for download on the network.

// public/locales/en/translation.json
{
  "hello_world": "Hello, World!"
}

// public/locales/ar/translation.json
{
  "hello_world": "مرحباً بالعالم!"
}Code language: JSON / JSON with Comments (json)

If we reload our app now, everything should work as it did before. However, if we look at the network tab in our browser’s dev console, we’ll notice some new requests.

HTTP request for our translation file.

One last thing here: Let’s add a React Suspense boundary so that on slow connections our users will get a helpful indicator while our active translation files downloading.

// src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./i18n/config.ts";
import "./index.css";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
+   <React.Suspense fallback={<div>Loading...</div>}>
      <App />
+   </React.Suspense>
  </React.StrictMode>,
);Code language: Diff (diff)

Now if our translation file is taking a while to load, the user will see a “Loading…” message instead of an empty page.

✋ The fallback locale(s) will always be loaded. So, with our current configuration, if the active locale is Arabic (ar), both Arabic and English (en) translation files will be loaded. This is because we designated en as the fallback locale when we configured i18next. If we’re missing an Arabic translation, we want our users to its English counterpart, so this makes sense.

🔗 You can override the translation file path and other options when you configure the HTTP API backend. Learn more on the official plugin page.

That’s the basics of async translation file loading done. With a few lines of code, we’ve made our app significantly more scaleable.

How do I get and set the active locale?

In our components and custom hooks, we can use the useTranslation() hook to get the i18next instance, called i18n. This object allows us to retrieve and set the active locale.

// In our components or hooks
import { useTranslation } from "react-i18next";

// ...

const { i18n } = useTranslation();

const activeLocale = i18n.resolvedLanguage; 
// => "en" when active locale is English

i18n.changeLanguage("ar");
// Active locale is now Arabic; components
// will re-render to reflect this.Code language: TypeScript (typescript)

We have two properties for accessing the active locale:

  • i18n.language is either the detected language (if we’re using browser detection, more on that later) or the one set directly via i18n.changeLanguage().
  • i18n.resolvedLanguage is the actual language used, after resolving fallback, and the one that has a corresponding translation file.

For example, let’s say we called i18n.changeLanguage("ar-SA"), attempting to change the language in our app to Saudi Arabian Arabic. We don’t have any ar-SA translation file, so i18next will fall back to our ar file. In this case:

  • i18n.language === "ar-SA"
  • i18n.resolvedLanguage === "ar"

🔗 Read more about the resolved language in the official docs.

How do I build a language switcher?

We often want a way for users to manually select their preferred locale. We can use the i18n members we just covered, i18n.resolvedLanguage and i18n.changeLanguage(), to build a locale-switching UI for our users.

First, let’s add a supported languages object in our configuration.

// src/i18n/config.ts

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

+ // Add names for each locale to
+ // show the user in our locale
+ // switcher.
+ export const supportedLngs = {
+   en: "English",
+   ar: "Arabic (العربية)",
+ };

  i18n
    .use(HttpApi)
    .use(initReactI18next)
    .init({
      lng: "en",
      fallbackLng: "en",
+     // Explicitly tell i18next our
+     // supported locales.
+     supportedLngs: Object.keys(supportedLngs),
      debug: true,
      interpolation: {
        escapeValue: false,
      },
    });

  export default i18n;Code language: Diff (diff)

We export our supportedLngs object because we’ll need it in our locale switcher. Speaking of which:

// src/i18n/LocaleSwitcher.tsx

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

export default function LocaleSwitcher() {
  const { i18n } = useTranslation();

  return (
    <div className="...">
      <div className="...">
        <select
          value={i18n.resolvedLanguage}
          onChange={(e) => i18n.changeLanguage(e.target.value)}
        >
          {Object.entries(supportedLngs).map(([code, name]) => (
            <option value={code} key={code}>
              {name}
            </option>
          ))}
        </select>
      </div>
    </div>
  );
}Code language: TypeScript (typescript)

🔗 Get the full code listing for LocaleSwitcher, including icon and styles, from our GitHub repo.

We use Object.entries to convert our { en: "English", ...} object to an array of arrays, [["en", "English"], ...]. We then destructure the elements of this array, converting them to <option value="en">English</option> elements for our <select>.

Plugging this <LocaleSwitcher> into our app header, we get this:

Switching between English and Arabic using the locale switcher UI.

📣 Thank you to The Icon Z for providing their Language icon on The Noun Project.

How do I automatically detect the user’s language?

Our solution so far has relied on the user understanding the default language (English in our case) and then selecting a different one if need be. But not everyone can read English. It might be a good idea to detect the language preferences in the user’s browser and present our website in a language as close to that as possible.

Luckily, an official i18next language detection plugin makes automatic language detection a breeze. Let’s install it.

npm install i18next-browser-languagedetectorCode language: Bash (bash)

Now we need to wire up the plugin when we configure the i18next instance.

// src/i18n/config.ts

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

  export const supportedLngs = {
    en: "English",
    ar: "Arabic (العربية)",
  };

  i18n
    .use(HttpApi)
+   .use(LanguageDetector)
    .use(initReactI18next)
    .init({
-     // We need to remove this explicit setting
-     // of the the active locale, or it will
-     // override the auto-detected locale.
-     lng: "en",
      fallbackLng: "en",
      supportedLngs: Object.keys(supportedLngs),
      debug: true,
      interpolation: {
        escapeValue: false,
      },
    });

  export default i18n;Code language: Diff (diff)

If we keep the lng setting in our config, it will always override the detected locale.

OK, let’s test out this new setup. We can open our browser’s language settings and make sure that Arabic is at the top of the list (or any language you support in your app).

Firefox language settings showing Egyptian Arabic at the top of the preference list.
Firefox language settings showing Egyptian Arabic at the top of the preference list.

If we visit our website now, we’ll see it displayed in Arabic. This is the language detector doing its work: It’s reading the browser setting exposed in the JavaScript navigator object and matching it with one of our app’s supported languages. Here, ar-EG causes a fallback to our supported ar, so our site is presented in Arabic.

Detection sources

By default, the i18next-browser-languagedetector goes through a cascade of sources when auto-detecting the locale. If it finds a locale in one of these it stops and resolves to that locale. Otherwise, it keeps going down the list. Here is the default cascade:

  1. The URL query string. You can try this: Visit /?lng=en and the site should switch to English.
  2. A cookie (named i18next by default) that stores the value of the last resolved locale.
  3. A localeStorage key (named i18nextLng by default) that stores the value of the last resolved locale.
  4. A sessionStorage key (named i18nextLng by default) that stores the value of the last resolved locale.
  5. The navigator object, which exposes the languages in the browser settings.
  6. The <html lang> attribute.

🔗 Much like other i18next features, the detector is highly configurable, and even supports detection from the URL path or subdomain. It even allows for custom detection logic. Check out the official detector docs for more info.

Caching the resolved locale

The default behavior of the detector is to cache the last locale it resolved in localStorage. So the first time a user visits our site, the detector will go through its normal cascade and land on a locale (likely one configured in the user’s browser or one of our fallbacks). Every time the user visits after that, the detector will read the value it stored in localStorage and use that locale without looking further.

This caching behavior happens on i18next.init() and i18n.changeLanguage(), so if the user manually selects a locale she prefers, this will override any other detection.

So basically we make a best effort to detect the user’s locale, but ultimately leave the choice to the user.

🤿 We dive into detecting the user’s locale on the server and browser, and even get into geolocation, in our dedicated guide, Detecting a User’s Locale in a Web App.

How do I work with right-to-left languages?

Hundreds of millions of people in the world read and speak right-to-left languages like Arabic, Hebrew, Urdu, and more. Yet RTL (right-to-left) is often an afterthought in localization efforts. Some years ago supporting RTL was a bit of a pain. Today modern browsers simplify the process of supporting RTL document flow, handling most of the complexities. Developers only need to perform minimal adjustments in their applications to fully support RTL.

i18next can detect the directionality of a given locale via itsi18n.dir() method. Let’s use it to write a custom hook for setting the document direction depending on the active locale.

// src/i18n/useLocalizeDocumentAttributes.ts

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

export default function useLocalizeDocumentAttributes() {
  const { i18n } = useTranslation();

  useEffect(() => {
    if (i18n.resolvedLanguage) {
   
      // Set the <html lang> attribute.
      document.documentElement.lang = i18n.resolvedLanguage;

      // Set the <html dir> attribute.
      document.documentElement.dir = i18n.dir(i18n.resolvedLanguage);
    }
  }, [i18n, i18n.resolvedLanguage]);
}Code language: TypeScript (typescript)

🗒️ Recall that resolvedLanguage is the language we support that best matches the selected or detected language. The resolvedLanguage is effectively the active locale.

To change the overall document direction to RTL, we just need to set <html dir="rtl">. All modern browsers support this and will reflow the page accordingly. We access the <html> element via document.documentElement.

🗒️ It’s also good practice to set the <html lang> attribute (it helps with accessibility, user experience, search engine optimization, and more).

Let’s add our new hook to our <App> component to see it in action.

// src/App.tsx

  import { useTranslation } from "react-i18next";
+ import useLocalizeDocumentAttributes from "./i18n/useLocalizeDocumentAttributes";
  import Header from "./layout/Header";
 
  function App() {
    const { t } = useTranslation();
 
+   useLocalizeDocumentAttributes();
 
    return (
      <div className="...">
        {/* ... */} 
      </div>
    );
 }

 export default App;Code language: Diff (diff)
Our app now flows right-to-left when shown in Arabic.

🔗 i18n.dir() is part of the i18n object API.

🗒️ There’s a bit more to RTL than just flipping the <html dir>. For example, we often need to make sure that our horizontal spacing (margin, padding) is in the correct direction. Using CSS logical properties (margin-block-start instead of margin-left) can help with this.

How do I localize the document title?

Of course, our page’s <title> is very important for search engine optimization (SEO) and our user’s experience — the title is shown in the browser tab after all. Localizing a page title is straightforward. First, we add translation messages for the page’s title:

// public/locales/en/translation.json
 {
+  "app_title": "React + i18next Playground",
   "hello_world": "Hello, World!"
 }

// public/locales/ar/translation.json
 {
+  "app_title": "ملعب ريأكت و أي إيتين نكست",
   "hello_world": "مرحباً بالعالم!"
 }Code language: Diff (diff)

Next, we update our useLocalizeDocumentAttributes hook to set the document title.

// src/i18n/useLocalizeDocumentAttributes.ts

 import { useEffect } from "react";
 import { useTranslation } from "react-i18next";
 
 export default function useLocalizeDocumentAttributes() {
-  const { i18n } = useTranslation();
+  const { t, i18n } = useTranslation();
 
   useEffect(() => {
     if (i18n.resolvedLanguage) {
       document.documentElement.lang = i18n.resolvedLanguage;
       document.documentElement.dir = i18n.dir(i18n.resolvedLanguage);
     }

+    // 👇 Localize document title.
+    document.title = t("app_title");

-  }, [i18n, i18n.resolvedLanguage]);
+  }, [i18n, i18n.resolvedLanguage, t]);
 }Code language: Diff (diff)
Our page’s title shown in English.
Our page’s title shown in English.
Our page’s title shown in Arabic.
Our page’s title shown in Arabic.

How do I work with dynamic values in my translations?

We often have strings that must be injected into translation messages at runtime. A good example of this is the logged in user’s name e.g. “Hello, username!” where username should be replaced at runtime and not hard-coded into the message.

i18next supports this through interpolation: specifying a variable in a translation message and swapping it out at runtime. We use a {{variable}} syntax in our messages to achieve this. Let’s add a new translation message with interpolated variables:

// public/locales/en/translation.json

  {
    "app_title": "React + i18next Playground",
    "hello_world": "Hello, World!"
+   "user_greeting": "Hello, {{firstName}} {{lastName}} 👋"
  }

// public/locales/ar/translation.json
 
  {
    "app_title": "ملعب ريأكت و أي إيتين نكست",
    "hello_world": "مرحباً بالعالم!",
+   "user_greeting": "أهلاً بك {{firstName}} {{lastName}} 👋"
  }Code language: Diff (diff)

We can now use the firstName and lastName identifiers as params when we call t(), swapping in their actual values at runtime.

// In our component

import { useTranslation } from "react-i18next";

// A pretend service just to demonstrate.
import { useLoggedInUser } from "../some/fake/service";

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

  return (
    <p>
      {t("user_greeting", {
        // 👇 Interpolate these at runtime.
        firstName: user.firstName,
        lastName: user.LastName,
      })
    </p>
  );
}Code language: TypeScript (typescript)

The second param to t() can be a map of values to swap into the translation message at runtime. We can have as many values as we want in a message; we just need to remember to include a key/value in our map for each one we have in our message.

In our live StackBlitz demo, we use text inputs to create the interpolated values at runtime.

As we type, the values of the input fields are interpolated into a translation message at runtime.

🔗 Try the StackBlizt demo for yourself. You can also grab the interpolation code from GitHub.

🤿 As usual, i18next provides a lot of options for interpolation, including changing the {{}} specifiers, passing in entire objects, unescaping HTML, and more. The official Interpolation docs have you covered here.

How do I work with plurals in my translations?

While English has straightforward singular and plural forms (like “apple” vs “apples”), other languages like Russian and Arabic have more complex pluralization rules. Luckily, i18next makes this complexity easy to navigate. For a plural message, we simply need to provide each of its plural forms in the current language.

// public/locales/en/translation.json

{
    // ...
    "trees_grown_one": "We have grown one tree 🌳",
    "trees_grown_other": "We have grown {{count}} trees 🌳"
}Code language: JSON / JSON with Comments (json)

Again, English has two plural forms, one and other. i18next uses a suffix syntax to specify all the plural forms for a message, just like you see above. In our components, we use these as a single message, dropping the suffixes from the key.

{/* Notice how we don't use _one or _other here. */}
<p>{t("trees_grown", { count: 3 })}</p>Code language: TypeScript (typescript)

When i18next sees the special count variable, it knows it’s working with a plural message, and resolves the appropriate plural form based on the count.

As we change the value of count in the number field, the English plural form is automatically selected by i18next at runtime.

🗒️ You may have noticed that the zero case above has a separate message. While zero is not an official plural form in English, i18next always supports the zero case. For the above, we just added a trees_grown_zero message to our English translation file.

Working with complex plurals

While many languages have one | other plural forms, many others don’t. Arabic, for example, has six plural forms. i18next makes it easy to add these using the same suffix syntax.

// public/locales/ar/translation.json

{
  // ...
  "trees_grown_zero": "لم نزرع أي شجرة بعد",
  "trees_grown_one": "لقد زرعنا شجرة واحدة 🌳",
  "trees_grown_two": "لقد زرعنا شجرتين 🌳",
  "trees_grown_few": "لقد زرعنا {{count}} أشجار 🌳",
  "trees_grown_many": "لقد زرعنا {{count}} شجرة 🌳",
  "trees_grown_other": "لقد زرعنا {{count}} شجرة 🌳"
}Code language: JSON / JSON with Comments (json)

Again, we don’t need to change our t() call at all here. t("trees_grown", { count: 1 }) works just as it did before. i18next sees count and knows to resolve a plural form in the active locale (Arabic in this case).

As we change the count variable, i18next automatically selects the correct Arabic plural form for the message.
A count of 3 will resolve to the Arabic plural few form.

🗒️ You might be wondering where to find the plural forms for a language you’re not familiar with. There’s a handy web tool that you can use to get the correct suffixes for a language. The canonical source is the Language Plural Rules listing for the CLDR (Common Locale Data Repository).

Using the correct count numerals

Let’s fix one issue here before we move on: In the Arabic messages above, we’re showing the same numerals we use in English (0, 1, 2, …). However, in many Arabic regions, the official numerals are Eastern Arabic numerals (٠,١,٢, …). To ensure that i18next formats our counts appropriately for the active locale, we must specify the number format in our translation messages.

// public/locales/en/translation.json

 {
     // ...
     "trees_grown_one": "We have grown one tree 🌳",
-    "trees_grown_other": "We have grown {{count}} trees 🌳"
+    "trees_grown_other": "We have grown {{count, number}} trees 🌳"
 }

// public/locales/ar/translation.json

 {
   // ...
   "trees_grown_zero": "لم نزرع أي شجرة بعد",
   "trees_grown_one": "لقد زرعنا شجرة واحدة 🌳",
   "trees_grown_two": "لقد زرعنا شجرتين 🌳",
-  "trees_grown_few": "لقد زرعنا {{count}} أشجار 🌳",
+  "trees_grown_few": "لقد زرعنا {{count, number}} أشجار 🌳",
-  "trees_grown_many": "لقد زرعنا {{count}} شجرة 🌳",
+  "trees_grown_many": "لقد زرعنا {{count, number}} شجرة 🌳",
-  "trees_grown_other": "لقد زرعنا {{count}} شجرة 🌳"
+  "trees_grown_other": "لقد زرعنا {{count, number}} شجرة 🌳"
 }Code language: Diff (diff)

With that update, our Arabic plurals look correct.

Arabic plural translations showing the interpolated count in Eastern Arabic numerals.

✋ In fact, this isn’t a bullet-proof solution for rendering our localized count, since we’re currently relying on the browser’s default region for Arabic when formatting the number. We fix this in the next section when we implement a custom number formatter.

🤿 Go deeper with Pluralization: A Guide to Localizing Plurals, where we cover the powerful ICU Message Syntax and ordinal plurals, all while using i18next.

🔗 Play with plurals in our StackBlitz demo. You can also grab the plural code from GitHub.

How do I format localized numbers?

i18n is more than just string translations. Working with numbers and dates is crucial for most apps, and each region of the world handles number and date formatting differently.

A note on regional formatting

Number and date formatting are determined by region, not just language. For example, the US and Canada both use English but have different date formats and measurement units. So it’s better to use a qualified locale (like en-US) instead of just a language code (en).

Using a language code alone, such as ar for Arabic, can lead to inconsistencies. Different browsers might default to various regions, like Saudi Arabia (ar-SA) or Egypt (ar-EG), resulting in varied date formats due to distinct regional calendars.

In our tutorials, we usually use qualified locales (like en-US, ar-EG) to avoid such ambiguities. However, i18next can be challenging when working with fallbacks to a qualified locale: It always wants to fall back to the language-only version, so it’s difficult to set a qualified locale as a default fallback. So we’ll keep en and ar as our supported app locales and use custom formatters to enforce explicit regional formatting (more on custom formatters shortly).

🗒️ Alternatively, we could use custom fallback logic to override i18next’s insistence on language-only fallbacks. See the i18next docs Fallback section for an example of writing a custom fallback function.

Numbers in messages

We touched on number formatting in our translation messages earlier when we formatted the count variable in our plurals. At its most basic, providing the number format specifier to an interpolated number in a message gets us default number formatting:

// public/locales/en/translation.json
{
  // ...
  "simple_number": "A simple number (default formatting): {{value, number}}"
}

// public/locales/ar/translation.json
{
  // ...
  "simple_number": "رقم بالتنسيق الإفتراضي: {{value, number}}"
}Code language: JSON / JSON with Comments (json)

And in our components:

<p>{t("simple_number", { value: 2.04 })</p>Code language: TypeScript (typescript)
A number in an English translation message, using default formatting.
A number in an English translation message, using default formatting.
A number in an Arabic translation message, using default formatting.
A number in an Arabic translation message, using default formatting.

The number formatter that i18next provides uses the JavaScript standard Intl.NumberFormat object under the hood. Intl.NumberFormat allows for many formatting options, and they’re all available to us when we use the number formatter. We just need to specify the options using a number(option1: value1; option2: value2 ...) syntax. Here are some examples:

// public/locales/en/translation.json
{
  // ...
  "simple_number": "A simple number (default formatting): {{value, number}}",
  "percent": "Percentage (use values between 0 and 1): {{value, number(style: percent)}}",
  "custom_number": "Custom formatting: {{value, number(minimumFractionDigits: 2; maximumFractionDigits: 4; signDisplay: always)}}"
}

// In our components
<p>{t("simple_number", value: 0.333333)}</p>
<p>{t("percent", value: 0.333333)}</p>
<p>{t("custom_number", value: 0.333333)}</p>Code language: TypeScript (typescript)

The number 0.333333 formatted in different ways, depending in the format specifier in the message.

With number(...), any key/value pair between the parentheses will be passed in the second, options param of the Intl.NumberFormat constructor.

🔗 Check out the MDN docs for Intl.NumberFormat to see all the options available to you.

Of course, we can’t forget our other language(s):

// public/locales/ar/translation.json
{
  // ...
  "simple": "رقم بالتنسيق الإفتراضي: {{value, number}}",
  "percent": "النسبة المئوية (استخدم القيم بين ٠  و ١): {{value, number(style: percent)}}",
  "custom_number": "تنسيق مخصص: {{value, number(minimumFractionDigits: 2; maximumFractionDigits: 4; signDisplay: always)}}"
}Code language: JSON / JSON with Comments (json)

The number 0.333333 represented in different formats in Arabic messages, depending on the format specified in each message.

🤿 We can of course format currency this way as well. i18next also provides a currency shortcut: {{value, currency(USD)}}. Read about that and more, including how to pass number formatting options when calling t(), in the official Number Formatting docs.

Custom formatters

To solve the region ambiguity issue we mentioned in the above section, we can make use of i18next’s custom formatters, providing one of our own for numbers. This custom formatter will override the default number formatter provided by i18next, ensuring we always use a qualified locale (e.g. ar-EG) when formatting numbers.

Remember, if we don’t do this, we leave it up to the browser to decide the region. For example, the Arc browser (Chrome-based) displays our Arabic numbers as 0, 1, 2 numerals. As we mentioned earlier, many Arabic regions prefer the ١، ٢، ٣ (Eastern Arabic) numeral system.

The Arc browser does not use Eastern Arabic numerals for Arabic numerals by default.
The Arc browser does not use Eastern Arabic numerals for Arabic numerals by default.

This is likely due to the default region that Arc assumes for Arabic.

OK, onto the fix. Let’s create a new file to store our custom formatters and add our number formatter to it.

// src/i18n/formatters.tsx 

/**
 * Returns the default qualified locale code
 * (language-REGION) for the given locale.
 *
 * @param lng - The language code.
 * @returns The qualified locale code, 
*  including region.
 */
function qualifiedLngFor(lng: string): string {
  switch (lng) {
    // Use Egypt as the default formatting
    // region for Arabic.
    case "ar":
      return "ar-EG"; 
    // Use USA as the default formatting
    // region for English.
    case "en":
      return "en-US";
    default:
      return lng;
  }
}

/**
 * Formats a number.
 *
 * @param value - The number to format.
 * @param lng - The language to format the number in.
 * @param options - passed to Intl.NumberFormat.
 * @returns The formatted number.
 */
export function number(
  value: number,
  lng: string | undefined,
  options?: Intl.NumberFormatOptions,
): string {
  return new Intl.NumberFormat(
    qualifiedLngFor(lng!),
    options,
  ).format(value);
}Code language: TypeScript (typescript)

Let’s wire the new number formatter up to our i18next instance:

// src/i18n/config.ts

  import i18next from "i18next";
  import LanguageDetector from "i18next-browser-languagedetector";
  import HttpApi from "i18next-http-backend";
  import { initReactI18next } from "react-i18next";
+ import { number } from "./formatters";

  // ...

  i18next
    .use(HttpApi)
    .use(LanguageDetector)
    .use(initReactI18next)
    .init({
      // ...
    });

+ i18next.services.formatter?.add("number", number);

  export default i18next;Code language: Diff (diff)

Now whenever we use the number or number(...) specifiers in our translation messages, i18next will use our custom number formatter instead of its default. The library will pass our formatter function the following:

  • value — The runtime number to be interpolated and formatted.
  • lng — The active resolved locale, en or ar.
  • options — Any options we specified between parentheses when calling number(...). These are parsed to an options object ready for the Intl.NumberFormat constructor.

🔗 Check out the Adding custom format function section in the official docs for more info, including how to cache custom formatters for performance.

In our formatter function, we take these values and use our own instance of the standard Intl.NumberFormat object to format the number. We ensure that the locale passed to Intl.NumberFormat always has an explicit region by using qualifiedLngFor().

With our new formatter configured, the Arc browser will now adhere to the Egypt region when formatting our Arabic numbers. In fact, this behavior should now be consistent across all browsers.

The Arc browser now uses Eastern Arabic numerals to render Arabic numbers (except currency).
The Arc browser now uses Eastern Arabic numerals to render Arabic numbers (except currency).

Notice that our currency value is still using Western Arabic numerals (1, 2, 3). This is because we’re formatting it using i18next’s shorthand currency formatter:

// public/locales/ar/translation.json

{
  // ...
  "currency": "تنسيق العملة: {{value, currency(USD)}}"
}Code language: JSON / JSON with Comments (json)

To fix this, we just need to add another custom formatter that overrides currency:

// src/i18n/formatters.tsx

  function qualifiedLngFor(lng: string): string {
    switch (lng) {
      case "ar":
        return "ar-EG";
      case "en":
        return "en-US";
      default:
        return lng;
    }
  }

  export function number(
    value: number,
    lng: string | undefined,
    options?: Intl.NumberFormatOptions,
  ): string {
    return new Intl.NumberFormat(
      qualifiedLngFor(lng!),
      options,
    ).format(value);
  }

+ export function currency(
+   value: number,
+   lng: string | undefined,
+   options?: Intl.NumberFormatOptions,
+ ): string {
+   // Use the number formatter above...
+   return number(value, lng, {
+     // ...but ensure we're formatting
+     // as currency.
+     style: "currency",
+     ...options,
+   });
+ }Code language: Diff (diff)

Of course, we have to wire up this new formatter for it to take effect:

// src/i18n/config.ts

  //...
- import { number } from "./formatters";
+ import { currency, number } from "./formatters";

  // ...

  i18next
    .use(HttpApi)
    .use(LanguageDetector)
    .use(initReactI18next)
    .init({
      // ...
    });

  i18next.services.formatter?.add("number", number);
+ i18next.services.formatter?.add("currency", currency);

  export default i18next;Code language: Diff (diff)

This ensures that our currency shorthand formatter adheres to our new explicit regions when formatting:

The Arc browser renders our currency(USD) format using Eastern Arabic numerals when the active locale is Arabic.
The Arc browser renders our currency(USD) format using Eastern Arabic numerals when the active locale is Arabic.

🔗 Check out our live StackBlitz demo to play with localized number formats, including standalone numbers (outside of translation messages). You can always grab that number code from GitHub as well.

🤿 Our Concise Guide to Number Localization covers numeral systems and other goodies relating to localized numbers in detail.

How do I format localized dates?

Rounding out our foray into formatting, let’s localize our dates.

✋ This section builds on the previous one, so please read the numbers section before this one.

As you probably guessed, i18next provides a built-in date formatter for our messages. It’s called datetime and uses the standard Intl.DateTimeFormat object under the hood. Here’s how to use it:

// public/locales/en/translation.json
{
  // ...
  "simple_date": "A simple date (default formatting): {{value, datetime}}"
}

// public/locales/ar/translation.json
{
  // ...
  "simple_date": "تاريخ بسيط (التنسيق الافتراضي): {{value, datetime}}"
}Code language: JSON / JSON with Comments (json)

In our components, we need to provide either a Date object, or UTC timestamp, as the value to format:

// In our components

{/* Using a `Date` object. */}
<p>
  {t("simple_date", {
    value: new Date("2024-01-25"),
   })}
</p>

{/* Using a UTC timestamp `number` */}
<p>
  {t("simple_date", { value: 1706140800000 })}
</p>Code language: TypeScript (typescript)

The dates above are equivalent, so the datetime formatter treats them as the same:

The default datetime formatter for English (en) rendered in Firefox.
The default datetime formatter for English (en) rendered in Firefox.
The default datetime formatter for Arabic (ar) rendered in Firefox.
The default datetime formatter for Arabic (ar) rendered in Firefox.

A custom datetime formatter

Just like numbers, dates are region-specific. Some regions might share a language but use entirely different calendars. And just like numbers, if we don’t specify a date to Intl.DateTimeFormat — used by i18next’s datetime formatter under the hood — we let each browser decide the region for us. This can cause inconsistent formatting across browsers. For example, here’s how the Arc browser formats the above Arabic dates.

By default, the Chrome-based Arc browser formats Arabic (ar) dates in the Gregorian calendar and doesn’t use the Eastern Arabic numeral system.
By default, the Chrome-based Arc browser formats Arabic (ar) dates in the Gregorian calendar and doesn’t use the Eastern Arabic numeral system.

We’ve already solved this problem for numbers. We just need to add a new formatter for our datetimes.

// src/i18n/formatters.tsx

// ...
function qualifiedLngFor(lng: string): string {
  switch (lng) {
    case "ar":
      return "ar-EG";
    case "en":
      return "en-US";
    default:
      return lng;
  }
}

// ...

+ /**
+  * Formats a datetime.
+  *
+  * @param value - The datetime to format.
+  * @param lng - The language to format the number in.
+  * @param options - passed to Intl.DateTimeFormat.
+  * @returns The formatted datetime.
+  */
+  export function datetime(
+    value: Date | number,
+    lng: string | undefined,
+    options?: Intl.DateTimeFormatOptions,
+  ): string {
+    return new Intl.DateTimeFormat(
+      qualifiedLngFor(lng!),
+      options,
+    ).format(value);
+  }

// ...Code language: Diff (diff)

Our custom date formatter uses qualifiedLngFor just like our custom number formatters. It’s always explicitly using a region when formatting dates: USA for English and Egypt for Arabic.

We add our formatter to the i18next instance to override the default datetime formatter:

// src/i18n/config.ts

  //...
- import { currency, number } from "./formatters";
+ import { datetime, currency, number } from "./formatters";

  // ...

  i18next
    .use(HttpApi)
    .use(LanguageDetector)
    .use(initReactI18next)
    .init({
      // ...
    });

  i18next.services.formatter?.add("number", number);
  i18next.services.formatter?.add("currency", currency);
+ i18next.services.formatter?.add("datetime", datetime);

  export default i18next;Code language: Diff (diff)
With Egypt explicitly set as the default region for Arabic, the Arc browser will now format our dates using Egypt’s standards. This includes using Eastern Arabic numerals when formatting numbers.
With Egypt explicitly set as the default region for Arabic, the Arc browser will now format our dates using Egypt’s standards. This includes using Eastern Arabic numerals when formatting numbers.

Modifying the datetime format

Much like numbers, we can specify options for the datetime formatter that will be passed onto the Intl.DateTimeFormat constructor.

// public/locales/en/translation.json
{
  // ...
  "long_date": "Long date format: {{value, datetime(dateStyle: long)}}",
  "custom_date": "Custom date format: {{value, datetime(weekday: long; year: 2-digit; month: short; day: numeric)}}"
}

// In our components
<p>
  {t("long_date", { value: new Date("2024-01-25") })}
</p>
<p>
  {t("custom_date", {
    value: new Date("2024-01-25"),
  })}
</p>Code language: TypeScript (typescript)

The same date formatted in two different ways, each specified in an English translation message.

🔗 See the MDN docs for Intl.DateTimeFormat for all available formatting options.

And here’s the Arabic version:

// public/locales/ar/translation.json
{
  // ...
  "long_date": "تنسيق التاريخ الطويل: {{value, datetime(dateStyle: long)}}",
  "custom_date": "تنسيق التاريخ المخصص: {{value, datetime(weekday: long; year: 2-digit; month: short; day: numeric)}}"
}Code language: JSON / JSON with Comments (json)
The same date formatted in two different ways, each specified by a format in an Arabic translation message.
The same date formatted in two different ways, each specified by a format in an Arabic translation message.

🔗 Check out the i18next Formatting guide for more info about date formatting, including relative dates.

🔗 Our live StackBlitz demo has a date playground where you can try different dates and see them in different formats and locales.

An animation of dates being selected from a date picker as localized date formats update in lockstep.

🔗 Get the date code, and the code for the rest of the demo app, from our GitHub repo.

Further reading

Here are some more of our articles on React + i18next:

🔗 Of course, the official React i18next guide is always worth checking out.

Up your localization game

We hope you found this guide to localizing React apps with i18next fun and helpful.

When you’re ready to start translating, let Phrase Strings take care of the hard work. With plenty of tools to automate your translation process and native integrations with platforms like GitHub, GitLab, and Bitbucket, Phrase Strings makes it simple for translators to pick up your content and manage it in its user-friendly string editor.

Once your translations are ready, you can easily pull them back into your project with a single command—or automatically—so you can stay focused on the code you love. Sign up for a free trial and see for yourself why developers love using Phrase Strings for software localization.