Software localization

A Guide to Localizing React Apps with react-intl/FormatJS

Dive deep into localizing React apps with react-intl/FormatJS and discover how Phrase Strings can help you translate your content seamlessly.
React l10n and i18n blog post featured image | Phrase

At 20M NPM downloads per week and integrations in everything from WordPress to native mobile apps, it’s safe to say that React has become the de facto UI library for the web and beyond.

And when it comes to React app internationalization (i18n) and localization (l10n), the react-intl library is hard to beat. react-intl sits atop the FormatJS i18n libraries, themselves standards-focused and used by giants like Dropbox and Mozilla. react-intl also boasts over a million NPM downloads per week, so it’s beyond battle-tested.

In this hands-on guide, we’ll show you how to localize a React app step by step using the react-intl library. We’ll begin with the fundamentals of internationalization, then move on to working with translations, as well as handling date and number formatting.

As we progress, we’ll delve into more advanced topics such as message extraction and integrating a dedicated software localization platform like Phrase Strings. This integration will help us automate the localization process on a larger scale.

String Management UI visual | Phrase

Phrase Strings

Take your web or mobile app global without any hassle

Adapt your software, website, or video game for global audiences with the leanest and most realiable software localization platform.

Explore Phrase Strings

🔗 Resource » If you want a comparison of React i18n libraries, check out our best-off list of React libraries for internationalization.

What are internationalization (i18n) and localization (l10n)?

In practice, internationalization and localization often tend to be used interchangeably, but they don’t mean the same thing. Internationalization (i18n) is the process of making an app ready for localization: not hard-coding UI strings and using keyed translation instead, for example. Localization (l10n) is making an app useable by people of different cultures and languages—this often means the actual translation, using correct date formats, etc.

What is a locale?

A locale is a geographical region with a specific language. We represent a locale using a code: fr-CA means French from Canada, while fr-FR means French from France. We often interchange the terms language and locale, but it’s important to know the difference*.*

🔗 Resource » Here’s a handy list of locale codes.

Alright, let’s get practical.

Our demo app

We’ll work with a small app that we’ll localize step by step. Here it is before any i18n or l10n:

Yomtaba demo app screen | Phrase
Our fictional Yomtaba app displays a daily curated recipe for fellow foodies

📣 Shoutout » The burger image was created using Midjourney. Our app icon was created by Kawalan Icon from the Noun Project.

Package versions used

We used the following NPM packages when developing this app.

Library Version used Description
react 18.2.0 Our main UI library
react-intl 6.4.4 Used to localize our app
@formatjs/intl-localematcher 0.4.0 Helps detect the browser locale
tailwindcss 3.3.2 For styling; optional for our purposes.

Let’s get to building. We’ll spin up our app from the command line using Create React App.

npx create-react-app i18n-demoCode language: Bash (bash)

Our React components are presentational; the root <App> houses a <Header> and a <Recipe> card.

App component hierarchy screen | Phrase
Our component hierarchy

Let’s quickly run down the code in these components.

// src/App.js

import Header from "./components/Header";
import Recipe from "./components/Recipe";

function App() {
  return (
    <div>
      <Header />
      <Recipe />
    </div>
  );
}

export default App;Code language: JavaScript (javascript)

🗒️ Note » For brevity, we omit all style code in this tutorial, except styles that relate to localization. You can get all the code for this demo app from GitHub, including styles. (The i18n-demo-start directory contains the app before i18n.)

// src/components/Header.js

export default function Header() {
  return (
    <header>
      <img
        alt="App logo"
        src="/noun-recipe-2701716.svg"
      />
      <h1>Yomtaba</h1>
      <h2>·recipe of the day</h2>
    </header>
  );
}Code language: JavaScript (javascript)
// src/components/Recipe.js

import Nutrition from "./Nutrition";

export default function Recipe() {
  return (
    <main>
      <h2>Today's recipe</h2>
      <h3>Delightful Vegan Bean Burger</h3>

      <div>
        <div>
          <img
            src="/vegan_burger.jpg"
            alt="Vegan burger on a wooden plate"
          />
        </div>

        <div>
          <div>
            <p>by Rabia Mousa</p>
            <p>2023/6/20</p>
          </div>

          <div>
            <p>⏲️ 40min</p>
            <p>❤️ 2291</p>
          </div>

          <div>
            <Nutrition />
          </div>
        </div>
      </div>
    </main>
  );
}Code language: JavaScript (javascript)
// src/components/Nutrition.js

export default function Nutrition() {
  return (
    <table>
      <thead>
        <tr>
          <th colSpan={2}><h4>Nutrition</h4></th>
          <th>% Daily Value</th>
        </tr>
      </thead>

      <tbody>
        <tr>
          <td>Calories</td>
          <td>151</td>
          <td></td>
        </tr>
        <tr>
          <td>Fat</td>
          <td>1g</td>
          <td>2%</td>
        </tr>
        <!-- ... -->
      </tbody>
    </table>
  );
}Code language: JavaScript (javascript)

That’s about it for our starter code. Of course, all of our values are hard-coded into our components, and very much in need of localization. Shall we?

🔗 Resource » Get all the starter code from GitHub if you want to code along as we localize this app. Copy the i18n-demo-start directory, run npm install, and you should be good to go.

How do I localize my app with react-intl?

From a bird’s eye view, here’s how we localize our app:

  1. Install react-intl.
  2. Wrap our app hierarchy in an <IntlProvider>.
  3. Move hard-coded strings to translation message dictionaries.
  4. Use <FormattedMessage> and intl.formatMessage() to display these translation messages in our components.
  5. Localize our dates and numbers using react-intl’s <FormattedDate> and <FormattedNumber> respectively (and their function equivalents).

How does all that work in practice? Let’s take a look.

How do I install and set up react-intl?

We install react-intl via NPM. From the command line, run the following.

npm install react-intlCode language: Bash (bash)

Let’s set up some configuration. We’ll put our i18n logic under a new directory, src/i18n.

// src/i18n/i18n-config.js

// We'll use the English-USA locale when
// our app loads. It will also be used as
// a fallback when there's a missing
// translation in another locale.
export const defaultLocale = "en-US";

// The locales our app supports. We'll work
// with English-USA and Arabic-Egypt here.
// Feel free to add any locales you want.
export const locales = {
  // English translation message dictionary.
  "en-US": {
    // "x.y" is just a convention for keys, but
    // any string will do here.
    "app.title": "Yomtaba",
    "app.tagline": "recipe of the day",
  },
  // Arabic translation message dictionary.
  "ar-EG": {
    // Note that a message has to use the
    // same ID/key across locales.
    "app.title": "يومباتا",
    "app.tagline": "وصفة اليوم",
  },
};Code language: JavaScript (javascript)

We’ll revisit this configuration throughout this guide. Let’s continue our setup.

🔗 Resource » Installation is also covered in the official FormatJS installation docs.

The IntlProvider component

All of react-intl’s localization components need to be inside an <IntlProvider> to work. <IntlProvider> manages the active locale and translations, and ensures that any nested react-intl components show their values in the active locale. Let’s see this in action.

First, we’ll create an <I18n> component to encapsulate the <IntlProvider>. <I18n> will wrap our <App> and house i18n logic as we continue to build.

// src/i18n/I18n.js

import { IntlProvider } from "react-intl";

// Import the configuration we created earlier
import { defaultLocale, locales } from "./i18n-config";

export default function I18n(props) {
  return (
    {/* IntlProvider needs to be fed the active `locale`
        as well as the translation `messages` of the
        active locale. The `defaultLocale` is a
        fallback when there is a missing translation. */}
    <IntlProvider
      locale={defaultLocale}
      defaultLocale={defaultLocale}
      messages={locales[defaultLocale]}
    >
      {props.children}
    </IntlProvider>
  );
}Code language: JavaScript (javascript)

Alright, now we can wrap our entire app with <I18n>; we’ll do so in index.js.

// src/index.js

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

  const root = ReactDOM.createRoot(document.getElementById("root"));
  root.render(
    <React.StrictMode>
+      <I18n>
+        <App />
+      </I18n>
    </React.StrictMode>
  );Code language: Diff (diff)

Our hierarchy now looks like this:

.
└── I18n
    └── IntlProvider
        └── App
            └── ...Code language: plaintext (plaintext)

Our entire app is wrapped in a locale-aware <IntlProvider>. We can now localize our components. We’ve already added translation messages for our app’s name and tagline; let’s use those to update our <Header>. We’ll use react-intl’s <FormattedMessage> component to do this.

// src/components/Header.js

+ import { FormattedMessage } from "react-intl";

  export default function Header() {
    return (
      <header>
        <img
          alt="App logo"
          src="/noun-recipe-2701716.svg"
        />
        <h1>
-         Yomtaba
+         <FormattedMessage id="app.title" />
        </h1>
        ·
        <h2>
-         recipe of the day
+         <FormattedMessage id="app.tagline" />
        </h2>
      </header>
    );
 }Code language: Diff (diff)

react-intl’s <FormattedMessage> component takes an id prop and renders the corresponding translation message in the active locale. The wrapping <IntlProvider> will ensure that the active locale’s messages are used here.

If we reload our app now, it looks exactly the same as it did before. That’s because the active locale currently defaults to English.

What happens, however, if we change our default locale to Arabic?

// src/i18n/i18n-config.js

- export const defaultLocale = "en-US";
+ export const defaultLocale = "ar-EG";

export const locales = {
  "en-US": {
    "app.title": "Yomtaba",
    "app.tagline": "recipe of the day",
  },
  "ar-EG": {
    "app.title": "يومباتا",
    "app.tagline": "وصفة اليوم",
  },
};Code language: Diff (diff)

Presto.

Arabic translation display on an app screen | Phrase
Our app’s title and tagline displayed in Arabic

🤿 Go deeper » Read more about **FormattedMessage in the official API docs.

Using translation files

This is a good start, but we might want a more scaleable solution: All of our translations sitting in one configuration file can quickly get messy.

Let’s move our translations out of i18n-config.js and into new, per-locale translation files. We’ll put those under a src/lang directory.

// src/lang/en-US.json

{
  "app.title": "Yomtaba",
  "app.tagline": "recipe of the day"
}Code language: JSON / JSON with Comments (json)
// src/lang/ar-EG.json

{
  "app.title": "يومتابا",
  "app.tagline": "وصفة اليوم"
}Code language: JSON / JSON with Comments (json)

Now we can update our configuration to pull these files in.

// src/i18n/i18n-config.js

+ import enMessages from "../lang/en-US.json";
+ import arMessages from "../lang/ar-EG.json";

  export const defaultLocale = "en-US";

  export const locales = {
    "en-US": {
+     messages: enMessages,
-     "app.title": "Yomtaba",
-     "app.tagline": "recipe of the day",       
    },
    "ar-EG": {
+     messages: arMessages,
-     "app.title": "يومتابا",
-     "app.tagline": "وصفة اليوم"
    },
  };Code language: Diff (diff)

We round out our refactor with a quick update to our <I18n> component.

// src/i18n/I18n.js

import { IntlProvider } from "react-intl";
import { defaultLocale, locales } from "./i18n-config";

export default function I18n(props) {
  return (
    <IntlProvider
      locale={defaultLocale}
      defaultLocale={defaultLocale}
-     messages={locales[defaultLocale]}
+     messages={locales[defaultLocale].messages}
    >
      {props.children}
    </IntlProvider>
  );
}Code language: Diff (diff)

Our app should now work exactly as it did before, except now we can add more translations without bloating our i18n-config.js file. We can also easily pass translation files back and forth to translators.

How do I add a language switcher?

Let’s give our users a nice UI to be able to select the locale of their choice. Adding a language switcher will also make testing easier as we continue to localize the app. Once done, it will look like this:

Language switcher on an app screen | Phrase

We’ll need to update <IntlProvider>‘s locale and messages props to effectively change the rendered language. The problem is that our language switcher will need to sit deep inside the <IntlProvider>, making our hierarchy look something like this:

.
└── I18n
    └── IntlProvider
        └── App
            └── Header
                └── LangSwitcher
                    └── select
                        ├── option[English]
                        └── option[Arabic]Code language: plaintext (plaintext)

We want to let <IntlProvider> know when a new language is selected in the <LangSwitcher>.

We could connect an event through the <Header> and <App> components to the <IntlProvider>. This is known as “prop drilling”. It would create an unnecessary dependency chain, making it difficult to move the <LangSwitcher> within the app hierarchy later.

To avoid prop drilling, let’s add a piece of React context to manage our global locale state.

// src/i18n/LocaleContext.js

import { createContext } from "react";

export const LocaleContext = createContext({
  // Defaults that we'll override in a moment. 
  locale: "",
  setLocale: () => {},
});Code language: JavaScript (javascript)

Now let’s wire up this context to our <I18n> component.

// src/i18n/I18n.js

  import { IntlProvider } from "react-intl";
  import { defaultLocale, locales } from "./i18n-config";
+ import { useState } from "react";
+ import { LocaleContext } from "./LocaleContext";

  export default function I18n(props) {
+   // Add the active locale as component state.
+   const [locale, setLocale] = useState(defaultLocale);

    return (
+     // Expose the state and its setter to all descendent
+     // components.
+     <LocaleContext.Provider value={{ locale, setLocale }}>
        <IntlProvider
-         locale={defaultLocale}
+         locale={locale}
          defaultLocale={defaultLocale}
-         messages={locales[defaultLocale].messages}
+         messages={locales[locale].messages}
        >
          {props.children}
        </IntlProvider>
+     </LocaleContext.Provider>
  );
}Code language: Diff (diff)

We can now use LocaleContext in any descendent of <LocaleContext.Provider>, allowing us to set the top-level locale state practically anywhere in our hierarchy.

This means we can update the active locale in <IntlProvider> without passing props up or down our hierarchy. Let’s make use of this in our new <LangSwitcher>.

// src/i18n/LangSwitcher.js

import { useContext } from "react";
import { locales } from "./i18n-config";
import { LocaleContext } from "./LocaleContext";

export default function LangSwitcher() {
  // Pull in the top-level locale and its setter.
  const { locale, setLocale } = useContext(LocaleContext);

  return (
    <div>
      <select
        value={locale}
        // Whenever the user selects a locale, update the
        // top-level active locale.
        onChange={(e) => setLocale(e.target.value)}
      >
        {/* The keys of the `locales` config object
            are the locale codes: "en-US", "ar-EG". */}
        {Object.keys(locales).map((loc) => (
          <option value={loc} key={loc}>
            {loc}
          </option>
        ))}
      </select>
    </div>
  );
}Code language: JavaScript (javascript)

With that in place, we can select a new locale from the UI, triggering a re-render of all react-intl components, like <FormattedMessage>. This effectively switches the active locale. If we tuck our <LangSwitcher> inside our <Header> component, we should see the following.

Language switcher codes on an app screen | Phrase

Of course, we don’t want our users looking at locale codes like en-US when selecting their language. Let’s add human-friendly names to our locales.

// src/i18n/i18n-config.js

// ...

export const locales = {
  "en-US": {
+   name: "English",
    messages: enMessages,
  },
  "ar-EG": {
+   name: "Arabic (العربية)",
    messages: arMessages,
  },
};Code language: Diff (diff)

Now we can update our <LangSwitcher> to use these names.

// src/i18n/LangSwitcher.js

// ...

export default function LangSwitcher() {
  // ...

  return (
    <div>
      <select ...>
        {Object.keys(locales).map((loc) => (
          <option value={loc} key={loc}>
-           {loc}
+           {locales[loc].name}
          </option>
        ))}
      </select>
    </div>
  );
}Code language: Diff (diff)

Our language switcher now looks more readable. And because we used context, our solution is flexible: We can move our <LangSwitcher> anywhere within the scope of our <LocalContext.Provider> and it will continue to work.

Language switcher on an app screen | Phrase

📣 Shoutout » Language icon by jonata hangga on the Noun Project.

🔗 Resource » The official React docs’ **Passing Data Deeply with Context is a good guide on the subject.

How do I work with text direction (LTR/RTL)?

Arabic, Hebrew, Maldivian, and other languages are laid out right-to-left (rtl). Most others are left-to-right (ltr). Web browsers accommodate this through the <html dir="rtl"> attribute, which sets the layout of the whole page. Let’s tap into this attribute in a new custom hook.

// src/i18n/useDocL10n.js

import { useEffect } from "react";
import { useIntl } from "react-intl";
import { locales } from "./i18n-config";

export function useDocL10n() {
  // Get the active locale from the `intl`
  // instance.
  const { locale } = useIntl();

  // Update the <html dir> attr whenever
  // the locale changes.
  useEffect(() => {
    document.dir = locales[locale].dir;
  }, [locale]);
}Code language: JavaScript (javascript)

This is the first time we see the useIntl() hook. It returns an intl instance that has useful properties, like the active locale. (This is the same intl instance managed internally by our <IntlProvider>). We’ll revisit useIntl() throughout the article.

Let’s use our new custom hook in the root <App> component.

// src/App.js

  import Header from "./components/Header";
  import Recipe from "./components/Recipe";
+ import { useDocL10n } from "./i18n/useDocL10n";

  export default function App() {
+   useDocL10n();

    return (
      <div>
        <Header />
        <Recipe />
      </div>
    ); 
  }Code language: Diff (diff)
// src/App.js

  import Header from "./components/Header";
  import Recipe from "./components/Recipe";
+ import { useDocL10n } from "./i18n/useDocL10n";

  export default function App() {
+   useDocL10n();

    return (
      <div>
        <Header />
        <Recipe />
      </div>
    ); 
  }Code language: Diff (diff)

Notice that we’re referencing locales[locale].dir in our custom hook above. We need to add these direction configs to make the hook work.

// src/i18n/i18n-config.js

// ...

export const locales = {
  "en-US": {
    name: "English",
    messages: enMessages,
+   dir: "ltr",
  },
  "ar-EG": {
    name: "Arabic (العربية)",
    messages: arMessages,
+   dir: "rtl",
  },
};Code language: Diff (diff)

Now when Arabic is the active locale, the <html> document element will have a dir="rtl" value; when English is active it has dir="ltr".

Right-to-left text direction example | Phrase
Just by setting, our app is laid out right-to-left

🗒️ Note » You can set the dir attribute on many HTML elements to override the page’s dir.

Updating horizontal styles

Setting the dir attribute to rtl on the <html> will flow the page right-to-left. However, we often still need to update CSS that uses properties like margin-left. Here’s an example:

/* Apply when the document is left-to-right. */
[dir="ltr"] .card {
  padding-left: 0.25rem;
}

/* Apply when the document is right-to-left. */
[dir="rtl"] .card {
  padding-right: 0.25rem;
}Code language: CSS (css)

🤿 Go deeper » Alternatively, we can use the newer *logical* properties, like padding-inline-start, which cover text direction automatically.

🗒️ Note » Tailwind CSS has built-in direction modifiers e.g. ltr:pr-1 rtl:pl-1. The framework also supports logical properties, like ps-1 for padding-inline-start.

What are the 2 ways of formatting in react-intl?

Let’s review the basic translation message workflow as we look at react-intl’s 2 ways of formatting: declarative and imperative.

We’ll add translations for our recipe headers next.

// src/lang/en-US.json

{
  "app.title": "Yomtaba",
  "app.tagline": "recipe of the day",
  "app.logo_alt": "Yomtaba logo",
+ "recipe.title_label": "Today's recipe",
+ "recipe.title": "Delightful Vegan Bean Burger",
}Code language: Diff (diff)
// src/lang/ar-EG.json

{
  "app.title": "يومتابا",
  "app.tagline": "وصفة اليوم",
  "app.logo_alt": "رمز يومتابا",
+ "recipe.title_label": "وصفة اليوم",
+ "recipe.title": "برغر الفاصوليا النباتي الرائع",
}Code language: Diff (diff)

Just as before, we’ll pull these translations into our <Recipe> component using react-intl’s <FormattedMessage> component.

// src/components/Recipe.js

+ import { FormattedMessage } from "react-intl";
 // ...

 export default function Recipe() {
   return (
     <main>
       <h2>
-        Today's recipe
+        <FormattedMessage id="recipe.title_label" />
       </h2>
       <h3>
-        Delightful Vegan Bean Burger
+        <FormattedMessage id="recipe.title" />
       </h3>

       {/* ... */}
     </main>
   );
 }Code language: Diff (diff)

This is a declarative way to format messages. In other words, we’re not concerned with how messages as formatted; just that the React component, <FormattedMessage>, will display a message in the active locale given its id.

As it happens, <FormattedMessage> uses an intl.formatMessage() function under the hood —that’s the how, the imperative way to format.

We can make use of intl.formatMessage() ourselves through the useIntl() hook. This comes in handy when we want to translate attributes or props. Let’s translate our recipe image’s alt attribute to illustrate.

// src/lang/en-US.json

{
  // ...
  "recipe.title_label": "Today's recipe",
  "recipe.title": "Delightful Vegan Bean Burger",
+ "recipe.img_alt": "Vegan burger on a wooden plate",
}Code language: Diff (diff)
// src/lang/ar-EG.json

{
  // ...
  "recipe.title_label": "وصفة اليوم",
  "recipe.title": "برغر الفاصوليا النباتي الرائع",
+ "recipe.img_alt": "برغر نباتي على طبق خشبي",
}Code language: Diff (diff)
// src/components/Recipe.js

- import { FormattedMessage } from "react-intl";
+ import { FormattedMessage, useIntl } from "react-intl";
  
  // ...

  export default function Recipe() {
+   // Retrieve the `intl` object holding the active
+   // locale and translation messages. This object
+   // is provided by the top-level `<IntlProvider>`.
+   const intl = useIntl();

    return (
      <main>
        <h2>
          <FormattedMessage id="recipe.title_label" />
        </h2>
        <h3>
          <FormattedMessage id="recipe.title" />
        </h3>
        <div>
          <div>
            <img
              src="/vegan_burger.jpg"
-             alt="Vegan burger on a wooden plate"
+             alt={intl.formatMessage({ id: "recipe.img_alt" })}
            />
          </div>

          {/* ... */}
        </div>
      </main>
    );
  }Code language: JavaScript (javascript)

intl.formatMessage() works much like the <FormattedMessage> component: Given an id, it returns the corresponding translation message in the active locale.

Recipe image alt tag translated into English and Arabic | Phrase
The recipe image alt tag translated into English and Arabic

🔗 Resource » Learn more about intl.formatMessage() in the FormatJS API docs.

Localizing the document title

Now that we know about formatMessage(), we can use it to translate our <html title> in our useDocL10n() custom hook.

// src/i18n/useDocL10n.js

  import { useEffect } from "react";
  import { useIntl } from "react-intl";
  import { locales } from "./i18n-config";

  export function useDocL10n() {
-   const { locale } = useIntl();
+   const { locale, formatMessage } = useIntl();

    useEffect(() => {
      document.dir = locales[locale].dir;

+     // Localize the <html title> attribute.
+     document.title = formatMessage({ id: "app.title" });

-   }, [locale]);
+   }, [locale, formatMessage]);
  }Code language: Diff (diff)

With that, our document title is localized.

How do I add dynamic values to translation messages?

We often want to inject runtime values in a translation string. This could be the name of the logged-in user, for example. The ICU message format used by FormatJS makes this interpolation easy. Let’s localize the author string of our recipe to demonstrate.

First, we’ll add new translation messages, and use the {variable} syntax for placeholders.

// src/lang/en-US.json

{
  // ...
  "recipe.title": "Delightful Vegan Bean Burger",
  "recipe.img_alt": "Vegan burger on a wooden plate",
+ // {author} and {publishedAt} will be
+ // replaced at runtime
+ "recipe.author": "by {author} on {publishedAt}"
}Code language: Diff (diff)
// src/lang/en-US.json

{
  // ...
  "recipe.title": "Delightful Vegan Bean Burger",
  "recipe.img_alt": "Vegan burger on a wooden plate",
+ // {author} and {publishedAt} will be
+ // replaced at runtime
+ "recipe.author": "by {author} on {publishedAt}"
}Code language: Diff (diff)

Now we can use the values prop in <FormattedMessage> to interpolate these dynamic values at runtime.

// src/components/Recipe.js

import { FormattedMessage, useIntl } from "react-intl";
// ...

export default function Recipe() {
  const intl = useIntl();

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

        <div>
          <div>
            <p>
+             {/* We specify the `values` we want to swap in
+                 via a key/value map. */}
+             <FormattedMessage
+               id="recipe.author"
+               values={{ author: "Rabia Mousa", publishedAt: "2023/06/20" }}
+             />
            </p>
          </div>

          {/* ... */}
        </div>
      </div>
    </main>
  );
}Code language: Diff (diff)

That will do it.

Interpolation screen example | Phrase
Interpolated values in English and Arabic messages

🗒️ Note » The date in Arabic looks off: It’s injected as a hard-coded string and hasn’t been localized. We’ll fix it when we get to date formatting a bit later.

Of course, the imperative intl.formatMessage() provides interpolation as well.

<p>
  {/* The second parameter to `formatMessage` can be
      a map for swapping in dynamic values. */}
  {intl.formatMessage(
    { id: "recipe.author" },
    { author: "Rabia Mousa", publishedAt: "2023/06/20" }
  )}
</p>Code language: JavaScript (javascript)

How do I work with plurals in translation messages?

“You have 1 new message.”

“You have 29 new messages.”

Ah, plurals. They’re often mishandled in translation. It’s important to realize that different languages have different plural forms. While English has two plural forms*,* one and other, other languages can have more. Arabic, for example, has six.

🔗 Resource » The CLDR Language Plural Rules chart is a canonical source for languages’ plural forms.

Let’s add a comment counter to our recipe to showcase localized plurals.

Comment counter on an app screen | Phrase

We’ll add the English translation message first. The ICU message format has excellent support for plurals, using a special syntax:

 

{count, plural, 
  one {# comment}
  other {# comments}
}Code language: plaintext (plaintext)

Heads up » The other form is always required.

Of course, we need to add this message to our JSON language files. JSON doesn’t support multiline strings, so our message ends up looking like the following.

// src/lang/en-US.json

{
  // ...
  "recipe.author": "by {author}",
+ "recipe.comment_count": "{count, plural, one {# comment} other {# comments}}"
}Code language: Diff (diff)

The count variable in the message represents an integer that we can pass to <FormattedMessage> via its values prop.

// src/components/Recipe.js

import { FormattedMessage, useIntl } from "react-intl";
// ...

export default function Recipe() {
  // ...

  return (
    <main>
      {/* ... */}
          <div>
            <p>⏲️ 40min</p>
            <p>❤️ 2291</p>

+           <p>
+             <FormattedMessage
+               id="recipe.comment_count"
+               values={{ count: 419 }}
+
+            />
+           </p>
          </div>

      {/* ... */}
    </main>
  );
}Code language: Diff (diff)

FormatJS uses the count variable to determine the plural form within the recipe.comment_count message. It also replaces instances of # with the value of count in the string. In English, this results in the following renders for count values of 0, 1, and 2, respectively.

Plurals in English on an app UI screen | Phrase

Our Arabic translation message is more complex because it includes the language’s six plural forms.

{count, plural,
  zero {لا توجد تعليقات} 
  one {تعليق #}
  two {تعليقين #}
  few {# تعليقات}
  many {# تعليق}
  other {# تعليق}
}Code language: plaintext (plaintext)

Again, we need to squish it into one line for our JSON.

// src/lang/ar-EG.json

{
  // ...
  "recipe.author": "من {author}",
+ "recipe.comment_count": "{count, plural, zero {لا توجد تعليقات} one {تعليق #} two {تعليقين #} few {# تعليقات} many {# تعليق} other {# تعليق}}"
}Code language: Diff (diff)

🔗 Resource » I found a handy Online ICU Message Editor that I used for testing my multiline plural strings before copying them into my JSON and removing newline characters.

With our new translation in place, we get perfectly pluralized messages when we switch our active locale to Arabic. Also notice that the Arabic messages have Eastern Arabic numerals (١، ٢، ٣), which is correct for the Arabic-Egypt locale.

Plurals in Arabic on an app UI screen | Phrase

🤿 Go deeper » The Missing Guide to the ICU Message Format covers all the ins and outs of ICU message plurals. And our Concise Guide to Number Localization goes into detail regarding numeral systems.

How do I localize numbers?

We’ve just seen that we need to display numbers appropriately for the active locale. Not all locales use Western Arabic numerals (1, 2, 3). Bengali, for example, uses the Bengali–Assamese numeral system (০, ১, ২, ৩). Currency and percent symbols, large number separators (1,000,000), and more are specific to each region.

Heads up » This is why it’s important to use region-specific locale codes when working with numbers and dates. Use en-US, not en, for example.

Luckily, react-intl/FormatJS has good number localization support, which uses the JavaScript standard Intl.NumberFormat under the hood. Let’s make use of these react-intl features to localize the numbers in our React app.

We’ll start with the recipe “likes” counter.

Number of likes on an app UI screen before localization | Phrase

Here we can keep it simple and use react-intl’s <FormattedNumber> component.

// src/components/Recipe.js

- import { FormattedMessage, useIntl } from "react-intl";
+ import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";

  export default function Recipe() {
    const intl = useIntl();

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

            <div>
              <p>⏲️ 40min</p>

              <p>
-               ❤️ 2291
+               ❤️ <FormattedNumber value={2291} />
              </p>

              <p>
                <FormattedMessage
                  id="recipe.comment_count"
                  values={{ count: 419 }}
                />
              </p>
            </div>

        {/* ... */}
      </main>
    );
  }Code language: Diff (diff)

<FormattedNumber> localizes its given number value to the active locale.

Number of likes on an app UI screen after localization (Arabic) | Phrase
Our likes counter showing Eastern Arabic numerals and the Arabic thousands-separator

We can customize <FormattedNumber>‘s output: Anything that can be passed to the Intl.NumberFormat constructor options parameter can be passed as a prop to <FormattedNumber>. Here are some examples:

Number format overview | Phrase

We can pass the same options to react-intl’s imperative equivalent, intl.formatNumber().

<p>  
  {intl.formatNumber(0.11, { style: "percent" })}
</p>
{/* 
  en-US → "11%"
  ar-EG → "٪١١"
*/}Code language: JavaScript (javascript)

What about numbers interpolated in our translation messages? The ICU message syntax has us covered here.

// English
{
  "recipe.ingredient_price": "Estimated cost {cost, number, ::currency/USD}"
}

// Arabic
{
  "recipe.ingredient_price": "التكلفة التقديرية {cost, number, ::currency/USD}"
}Code language: JSON / JSON with Comments (json)

The syntax is similar to the plural syntax we used earlier. A last, optional, format designator after the :: is passed to the Intl.NumberFormat constructor.

🤿 Go deeper » The strange currency/USD syntax is a part of the ICU syntax known as a number skeleton.

// In our components, given the above messages...
<p>
  <FormattedMessage
    id="recipe.ingredient_price"
    values={{ cost: 18.42 }}
  />
</p>Code language: JavaScript (javascript)

The above would render the following in English and Arabic, respectively.

Inline number formatting screen | Phrase

How do I localize dates?

Localizing dates with react-intl is very similar to localizing numbers. We use react-intl’s <FormatteDate> and intl.formatDate(). In turn, react-intl uses the JavaScript standard Intl.DateTimeFormat under the hood to localize our dates.

Heads up » Just like numbers, dates are region-specific. So favor locales with regions (ar-EG) over language-only locales (ar).

Let’s revisit our earlier interpolation example.

// src/lang/en-US.json

{
  // ...
  "recipe.author": "by {author} on {publishedAt}",
  // ...
}Code language: JSON / JSON with Comments (json)
// src/lang/ar-EG.json

{
  // ...
  "recipe.author": "من {author} في {publishedAt}",
  // ...
}Code language: JSON / JSON with Comments (json)
// src/components/Recipe.js

// ...

export default function Recipe() {
  // ...

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

          <div>
            <p>
               <FormattedMessage
                 id="recipe.author"
                 values={{ 
                   author: "Rabia Mousa",
                   publishedAt: "2023/06/20",
                 }}
               />
            </p>
          </div>

      {/* ... */}
    </main>
  );
}Code language: JavaScript (javascript)

The above results in publishedAt rendering as “2023/06/20” in both English and Arabic. We can improve this by localizing the date value.

First, let’s update the publishedAt value itself, changing it to a Date object, which FormatJS needs for date localization.

// src/components/Recipe.js

// ...

export default function Recipe() {
  // ...

  return (
    {/* ... */}
            <p>
               <FormattedMessage
                 id="recipe.author"
                 values={{ 
                   author: "Rabia Mousa",
-                  publishedAt: "2023/06/20",
+                  // Remember, months are zero-indexed in
+                  // the `Date` constructor
+                  // ie. `0` is January.
+                  publishedAt: new Date(2023, 5, 20),
                 }}
             />
            </p>
    {/* ... */}
  );
}Code language: Diff (diff)

Now let’s use the ICU message syntax to designate a medium-length date in our messages.

// src/lang/en-US.json

{
  // ...
- "recipe.author": "by {author} on {publishedAt}",
+ "recipe.author": "by {author} on {publishedAt, date, medium}",
  // ...
}Code language: Diff (diff)
// src/lang/ar-EG.json

{
  // ...
- "recipe.author": "من {author} في {publishedAt}",
+ "recipe.author": "من {author} في {publishedAt, date, medium}",
  // ...
}Code language: Diff (diff)

With that, our date is localized.

Localized dates on an app UI screen | Phrase
Interpolated date localized for English and Arabic, respectively

Built-in short, medium, and long date formats are always available to us. We can also use ICU datetime skeletons to granularly control our formats. Let’s say we wanted to remove the day from our recipe publishing date. ICU date skeletons come in handy here:

// src/lang/en-US.json

{
  // ...
- "recipe.author": "by {author} on {publishedAt, date, medium}",
+ // Use month number (`M`) and two-digit year (`yy`) format.
+ "recipe.author": "by {author} on {publishedAt, date, ::Myy}",
  // ...
}Code language: Diff (diff)
// src/lang/ar-EG.json

{
  // ...
- "recipe.author": "من {author} في {publishedAt, date, medium}",
+ // Using inline message formats allows us to format dates
+ // differently for each locale. Here we use the full name of
+ // the month (`MMM`) and the four-digit year (`yyyy`).
+ "recipe.author": "من {author} في {publishedAt, date, ::MMMyyyy}",
  // ...
}Code language: Diff (diff)
Date skeletons on an app UI screen | Phrase
English and Arabic translations, respectively, with custom date formats

🔗 Resource » Find all the date skeletons FormatJS supports in the official docs.

What if we wanted to format a date outside of a translation message? We can use react-intl’s <FormattedDate> component for that. Let’s break our recipe’s publish date out of the translation message.

// src/components/Recipe.js

// ...

export default function Recipe() {
  // ...

  return (
    {/* ... */}
            <p>
               <FormattedMessage
                 id="recipe.author"
                 values={{ 
                   author: "Rabia Mousa",
-                  // Of course, we need to remove
-                  // `publishedAt` from our messages
-                  // as well.
-                  publishedAt: new Date(2023, 5, 20),
                 }}
               />
            </p>
+           <p>
+             <FormattedDate
+               value={new Date(2023, 5, 20)}
+               dateStyle="short"
+             />
+           </p>
    {/* ... */}
  );
}Code language: Diff (diff)

Much like <FormattedNumber>, <FormattedDate> takes formatting props that match the options param passed to the Intl.DateTimeFormat constructor.

Formatted date on an app UI screen | Phrase

And, of course, the imperative intl.formatDate() can be used to get the same result:

// src/components/Recipe.js

// ...

export default function Recipe() {
  // ...

  return (
    {/* ... */}
            <p>
-             <FormattedDate
-               value={new Date(2023, 5, 20)}
-               dateStyle="short"
-             />
+             // Renders the same result as the above
+             {intl.formatDate(new Date(2023, 5, 20), {
+               dateStyle: "short",
+             })}
            </p>
    {/* ... */}
  );
}Code language: Diff (diff)

🤿 Go deeper » react-intl offers many more datetime formatting options, including time, datetime range, relative time, and time zone support. See the component and imperative API docs for more info (the right-hand sidebars list the relevant components and functions, respectively).

How do I load translations dynamically?

Our current solution still isn’t as scalable as it could be. If we had a hundred, or even twenty locales, we would load them all in our main JavaScript bundle. This would waste bandwidth and slow down our website.

We can fix this. Let’s refactor our translation message logic so that we only load messages for the active locale. We can do this by dynamically loading the appropriate message file once we’ve determined the active locale.

First, let’s remove the static message file imports from our config file. We won’t need them anymore.

// src/i18n/i18n-config.js

- import enMessages from "../lang/en-US.json";
- import arMessages from "../lang/ar-EG.json";

  export const defaultLocale = "en-US";

  export const locales = {
    "en-US": {
      name: "English",
-     messages: enMessages,
      dir: "ltr",
    },
    "ar-EG": {
      name: "Arabic (العربية)",
-     messages: arMessages,
      dir: "rtl",
    },
  }; Code language: Diff (diff)

Next, let’s update our <I18n> wrapper component to load the appropriate translation file dynamically.

// src/i18n/I18n.js

- import { useState } from "react";
+ import { useEffect, useState } from "react";
  import { IntlProvider } from "react-intl";
- import { defaultLocale, locales } from "./i18n-config";
+ import { defaultLocale } from "./i18n-config";
  import { LocaleContext } from "./LocaleContext";
 
  export default function I18n(props) {
    const [locale, setLocale] = useState(defaultLocale);

+   // We hold the loaded translation messages in 
+   // a piece of state so we can set it in our
+   // useEffect and use it in our JSX.
+   const [messages, setMessages] = useState(null);
 
+   useEffect(() => {
+     // Force a re-render that shows our
+     // loading indicator.
+     setMessages(null);
+
+     // Load the messages using Webpack code-splitting
+     // via dynamic import.
+     import(`../lang/${locale}.json`)
+       .then((messages_) => setMessages(messages_))
+       .catch((err) =>
+         console.error(`Error loading messages for locale ${locale}: `, err)
+       );
+   }, [locale]);

-   return (
+   return !messages ? (
+     <p>Loading...</p>
+   ) : (
      <LocaleContext.Provider value={{ locale, setLocale }}>
        <IntlProvider
          locale={locale}
          defaultLocale={defaultLocale}
-         messages={locales[locale].messages}
+         messages={messages}
        >
          {props.children}
        </IntlProvider>
      </LocaleContext.Provider>
   );
 }Code language: Diff (diff)

If we open our browser dev tools and throttle our network speed to simulate a slow connection, we should be able to see our new loading state.

Asynchronous loading screen | Phrase

Under the hood, the Webpack bundler that Create React App uses has turned our dynamic import into a chunk that loads separately from the main bundle. We can see this if we open our browser Network tab.

Asynchronous initial loading | Phrase
On initial load, our English (default) messages are loaded separately from the main bundle
Async init loading screen (Arabic) | Phrase
When we switch to Arabic, Webpack loads our Arabic translations dynamically from the network

Our app works almost exactly as before, except now we can have as many locales as we want without bloating our main bundle.

How do I detect the user’s preferred language?

Not everyone can read English, and a good i18n strategy should accommodate that. Our user’s preferred languages are available to us through her browser’s navigator object. We can use this to load our app shown in the supported language closest to the user’s preference.

Let’s do this step-by-step. First, we’ll write a helper that returns an array of the locales set in the browser.

// src/i18n/browser-locales.js

export function browserLocales() {
  const result = [];

  // List of languages the user set in their
  // browser settings.
  if (navigator.languages) {
    for (let lang of navigator.languages) {
      result.push(lang);
    }
  }

  // UI language: language of browser and probably
  // operating system.
  if (navigator.language) {
    result.push(navigator.language);
  }

  return result;
}Code language: JavaScript (javascript)

navigator.languages corresponds to the list you can set in your own browser settings.

Firefox language options | Phrase

In case none are set in our user’s browser, we fall back on navigator.language.

We’ll use the browser locale to match one of our app’s supported locales. FormatJS has a handy locale matcher package, intl-localematcher, to help with just this. Let’s install it from the command line.

npm install @formatjs/intl-localematcherCode language: Bash (bash)

Now we write a function that takes both the browser locales and our app’s defined locales, and tries to find the closest match.

// src/i18n/user-locale.js

import { match } from "@formatjs/intl-localematcher";
import { defaultLocale, locales } from "./i18n-config";
import { browserLocales } from "./browser-locales";

export function userLocale() {
  const appLocales = Object.keys(locales);
  return match(browserLocales(), appLocales, defaultLocale);
}Code language: JavaScript (javascript)

FormatJS’s match is doing the heavy lifting here. Let’s briefly go over how it works.

Say we have Arabic-Morocco (ar-MA) and English-Canada (en-CA), in that order, as our preferred browser languages.

Language settings for Arabic (Morocco) in Firefox | Phrase

We know that our app’s locales are Arabic-Egypt (ar-EG) and English-USA (en-US). So in our scenario, the match() call above is equivalent to the following.

 
export function userLocale() {
  return match(
    ["ar-MA", "en-CA", "ar-MA"], // browser locales
    ["en-US", "ar-EG"],          // app locales
    "en-US"                      // default locale
  );
}Code language: JavaScript (javascript)

match looks at the browser locales and realizes that, ideally, our user wants content served in Arabic for the Morocco region. It checks against the second list and sees that while we don’t have that exact flavor of Arabic, we do have Egyptian Arabic. That’s the best our app can do, so match returns ar-EG.

🗒️ Note » When all else fails, match returns the value of its last param, the default locale.

Alright, let’s make use of this logic by wiring it up to the <I18n> component.

// src/i18n/I18n.js

  import { useEffect, useState } from "react";
  import { IntlProvider } from "react-intl";
  import { defaultLocale } from "./i18n-config";
  import { LocaleContext } from "./LocaleContext";
+ import { userLocale } from "./user-locale";

  export default function I18n(props) {
-   const [locale, setLocale] = useState(defaultLocale);
+   const [locale, setLocale] = useState(userLocale());
    const [messages, setMessages] = useState(null);

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

Now when a user visits our app, they’ll see it translated in a locale that’s closest to their preferred language.

🔗 Resource » Learn more in Detecting a User’s Locale in a Web App.

Storing the user-selected locale

What if the user wants to pick a language themselves, overriding the one we matched for them? We already have a language selector to allow them to do that. The problem now is that every time they visit our app, we’ll always use the detected/matched locale, ignoring any selection the user made in a previous visit.

We can fix this by utilizing the browser’s localStorage (that’s local not locale). localStorage allows us to store key/value pairs on the browser that we can retrieve on future site visits. Let’s add a simple module to store the user’s selected locale.

// src/i18n/stored-locale.js

const K_LOCALE = "locale";

// Retrieve the locale persisted in the browser.
export function getStoredLocale() {
  return localStorage.getItem(K_LOCALE);
}

// Persist the given locale in the browser.
export function setStoredLocale(newLocale) {
  localStorage.setItem(K_LOCALE, newLocale);
}Code language: JavaScript (javascript)

Now let’s make sure that if a user selects a locale using our language switcher, it gets stored for later retrieval.

// src/i18n/LangSwitcher.js

  import { useContext } from "react";
  import { locales } from "./i18n-config";
  import { LocaleContext } from "./LocaleContext";
+ import { setStoredLocale } from "./stored-locale";

  export default function LangSwitcher({ onLangChanged }) {
    const { locale, setLocale } = useContext(LocaleContext);

    return (
      <div>
				{/* ... */}
        <select
          value={locale}
          onChange={(e) => {
            setLocale(e.target.value);
+           setStoredLocale(e.target.value);
          }}
        >
        {Object.keys(locales).map((loc) => (
          <option value={loc} key={loc}>
            {locales[loc].name}
          </option>
        ))}
      </select>
    </div>
  );
}Code language: Diff (diff)

Finally, let’s update our userLocale() function to return the stored locale if it finds it.

// src/i18n/user-locale.js

  import { match } from "@formatjs/intl-localematcher";
  import { defaultLocale, locales } from "./i18n-config";
  import { browserLocales } from "./browser-locales";
+ import { getStoredLocale } from "./stored-locale";

  export function userLocale() {
+   const storedLocale = getStoredLocale();
+   if (storedLocale) return storedLocale;

    const appLocales = Object.keys(locales);
    return match(browserLocales(), appLocales, defaultLocale);
  }Code language: Diff (diff)

That’s it! Now if the user selects a locale from our language switcher, it will always be used on future visits, regardless of their browser settings.

How do I include HTML in my translation messages?

Sometimes we want to embed links or special styles within our translation messages. For example:

// In a component

<p>
  Try our <a href="/trial-landing">premium recipes</a> for free!
</p>Code language: JavaScript (javascript)

Luckily, react-intl makes it easy to embed markup in our translation messages using rich text formatting. Here’s how we can use it to translate the above message:

// English translation
{ 
  // Note the embedded <a> tag.
  "trial": "Try our <a>premium recipes</a> for free!"
}

// Arabic translation
{
  "trial": "جرب <a>وصفاتنا المميزة</a> مجانًا!"
}Code language: JSON / JSON with Comments (json)
// In our component

// We match the <a> with an `a` in our `values` object.
<p>
  <FormattedMessage
    id="trial"
    values={{
      // `a` is a function that takes a param 
      // containing what is *inside* <a></a>
      // in our message, and returns valid JSX.
      a: (chunks) => <a href="/trial-landing">{chunks}</a>
    }}
  />
</p>Code language: JavaScript (javascript)

The above would render:

<!-- English -->
<p>
  Try our <a href="/trial-landing">premium recipes</a> for free!
</p>

<!-- Arabic -->
<p>
  جرب <a href="/trial-landing">وصفاتنا المميزة</a> مجانًا!
</p>Code language: HTML, XML (xml)

We can have as many embedded tags in our messages as we want, as long as they have corresponding resolver functions in FormattedMessage‘s value prop.

And, of course, we can use rich text formatting in intl.formattedMessage() as well.

// Works just like <FormattedMessage>
<p>
  {intl.formatMessage(
    { id: "trial" },
    {
       a: (chunks) => <a href="/trial-landing">{chunks}</a>,
    }
  )}
</p>Code language: JavaScript (javascript)

🔗 Resource » Read more about rich text formatting in the official API documentation.

🗒️ Note » In earlier versions of react-intl, rich text formatting was achieved with a <FormattedHtmlMessage> component. This has been deprecated in favor of the above solution.

How do I extract translations from my app?

So far, we’ve covered a workflow where we manually set messages in translation files and reference them by ID in our components. As our app scales, this approach can become cumbersome, as we have to manually maintain increasingly large translation files.

As our app scales, we can automate this process. Let’s start with message extraction. Instead of placing messages in translation files ourselves, we can have the FormatJS CLI do it for us.

Additional packages used

For our extraction workflow, we will use the following NPM packages:

Library Version used Description
@formatjs/cli@6.1.3 6.1.3 CLI used to extract and compile translations
babel-plugin-formatjs 10.5.3 Auto-generates message IDs in our app builds
react-app-rewired 2.2.1 Allows overriding Create React App webpack config (for including babel-plugin-formatjs)
customize-cra 1.0.0 Allows overriding Create React App webpack config (for including babel-plugin-formatjs)

We’ll start by installing the FormatJS CLI, which will allow us to extract our messages. Running the following command from the command line should do it.

npm install --save-dev @formatjs/cliCode language: Bash (bash)

Let’s add a new script to our package.json file to make our lives easier.

// package.json

{
  "name": "i18n-demo",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    // ...
  },
  "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
+   "extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple",
  },

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

Our new extract script will use the FormatJS CLI to pull messages out of the .js and .jsx files in our source directory. The extracted messages will go into our source language file, src/lang/en-US.json.

Heads up » To avoid errors during extraction, we use the --ignore flag to tell the CLI to skip our translations (lang) directory, when looking for messages to extract.

Designating --format simple keeps the outputted en-US.json file in the {"id": "translation"} format we’ve been using so far. You can use the default FormatJS format by omitting the --format option. We’ll use the simple format in this tutorial.

🔗 Resource » Read about message extraction in the official docs. You might also find the CLI documentation handy.

If we run our extraction command now, en-US.json will contain ID keys and empty translation messages. That’s because, with an extraction workflow, we need to add our source/default (English) messages directly to our components. Let’s update our Header component to demonstrate.

// src/components/Header.js

import { FormattedMessage, useIntl } from "react-intl";
import LangSwitcher from "../i18n/LangSwitcher";

export default function Header() {
  const intl = useIntl();

  return (
    <header>
      <div>
        <img
          alt={intl.formatMessage({
            id: "app.logo_alt",
+           defaultMessage: "Yomtaba logo",
          })}
          src="/noun-recipe-2701716.svg"
        />
        <h1>
-         <FormattedMessage id="app.title" />
+         <FormattedMessage id="app.title" defaultMessage="Yomtaba" />
        </h1>
        ·
        <h2>
-         <FormattedMessage id="app.tagline" />
+         <FormattedMessage
+           id="app.tagline"
+           defaultMessage="recipe of the day"
+         />
        </h2>
      </div>

      <LangSwitcher />
    </header>
  );
}Code language: Diff (diff)

The defaultMessages we pass to formatMessage() and FormattedMessage are what the extract command will pull from our source code. They should be in the app’s default locale, English in our case.

🗒️ Note » When a translation is missing from the active locale, the contents of defaultMessage will be shown as a fallback.

Heads up » We need to make sure that the app.title declaration in our useDocL10n() hook matches the one in the Header component. Otherwise, we’ll get a warning that the messages don’t match when we try to extract (and an empty message in our translation file).

// src/i18n/useDocL10n.js

import { useEffect } from "react";
import { useIntl } from "react-intl";
import { locales } from "./i18n-config";

export function useDocL10n() {
  const { locale, formatMessage } = useIntl();

  useEffect(() => {
    document.dir = locales[locale].dir;
-   document.title = formatMessage({ id: "app.title" });
+   document.title = formatMessage({
+     id: "app.title",
+     defaultMessage: "Yomtaba",
+   });
  }, [locale, formatMessage]);
}Code language: Diff (diff)

Now let’s run our new command from the command line.

Heads up » Please make sure that every translation message in your app has a defaultMessage before running extract, or you will lose its translation in your source locale file.

npm run extractCode language: Bash (bash)

This should have overwritten our src/lang/en-US.json file, which should largely unchanged.

// src/lang/en-US.json

{
  "app.logo_alt": "Yomtaba logo",
  "app.tagline": "recipe of the day",
  "app.title": "Yomtaba"
  // ...
}Code language: JSON / JSON with Comments (json)

Except now we don’t have to maintain it manually. We can add and update translation messages in our app and npm run extract to update the translation file.

Heads up » The built-in react-intl React components will work fine with extract, as well as intl.formatMessage(). However, if you use any other components or functions for translation (like your own wrappers) you need to declare them using the CLI’s additional-function-names or additional-component-names options.

Of course, the translation workflow remains the same from here. We copy the en-US.json file to ar-EG.json, have a translator add the Arabic, and run the app as usual.

🔗 Resource » The i18n-demo-extraction directory in our GitHub repo has all of the code that we cover in this section.

Auto-generating IDs

We can further streamline our i18n process by removing message IDs from our source code entirely. Instead, FormatJS can automatically generate message IDs and use them under the hood.

🗒️ Note » FormatJS uses a hashing algorithm to generate message IDs that look like "7pboUV". It needs to be able to run the same algorithm when our app builds or runs in development so that it always generates the same ID for a given message. We’ll see all this in action as we go.

First, let’s remove the explicit custom IDs from our message definitions.

// src/components/Header.js

import { FormattedMessage, useIntl } from "react-intl";
import LangSwitcher from "../i18n/LangSwitcher";

export default function Header() {
  const intl = useIntl();

  return (
    <header>
      <div>
        <img
          alt={intl.formatMessage({
-           id: "app.logo_alt",
            defaultMessage: "Yomtaba logo",
          })}
          src="/noun-recipe-2701716.svg"
        />
        <h1>
          <FormattedMessage 
-           id="app.title"
            defaultMessage="Yomtaba"
          />
        </h1>
        ·
        <h2>
          <FormattedMessage
-           id="app.tagline"
            defaultMessage="recipe of the day"
          />
        </h2>
      </div>

      <LangSwitcher />
    </header>
  );
}Code language: Diff (diff)

Next, let’s modify the extract command so that it auto-generates message IDs.

// package.json 

{
  "name": "i18n-demo",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    // ...
  },
  "scripts": {
    "start": "react-app-rewired start",
    "build": "react-app-rewired build",
    "test": "react-app-rewired test",
    "eject": "react-scripts eject",
-   "extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple"
+   "extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple --id-interpolation-pattern '[sha512:contenthash:base64:6]'"
  },
  // ...
}Code language: Diff (diff)

The --id-interpolation-pattern option tells the extract command to generate a SHA-512 hash as an ID for any message that doesn’t have an explicit ID. (We don’t have to worry too much about the specifics of the hashing algorithm for our purposes).

Let’s run the npm run extract command to see the output. If we look at our English translation file now, we’ll see auto-generated IDs.

// src/lang/en-US.json

{
  "KDE5tg": "Yomtaba",
  "LwQO24": "recipe of the day",
  "ykciPr": "Yomtaba logo"
}Code language: JSON / JSON with Comments (json)

If we run our app now, we’ll get missing translation errors: FormatJS isn’t using its hashing algorithm at runtime, so it doesn’t know how to connect a message to its translation in a translation file.

To fix this, we need to use FormatJS’s Babel plugin, which means we need to update the Webpack configuration of our app. Create React App hides this configuration by default, so we’ll use react-app-rewired and customize-cra to hack into it.

Heads up » Create React App maintains its own build scripts to keep you focused on building your app. When you circumvent and update CRA’s build scripts, you’re responsible for maintaining these updates.

First, we’ll install the packages.

npm install --save-dev babel-plugin-formatjs react-app-rewired customize-craCode language: Bash (bash)

Next, let’s create a .babelrc file to tell Babel to use the FormatJS plugin.

// .babelrc

{
  "plugins": [
    [
      "formatjs",
      {
        "idInterpolationPattern": "[sha512:contenthash:base64:6]",
        "ast": true
      }
    ]
  ]
}Code language: JSON / JSON with Comments (json)

Now let’s create a config-overrides.js file; we’ll use it to configure react-app-rewired so that it feeds our .babelrc to Babel when running our app.

// config-overrides.js

const { useBabelRc, override } = require("customize-cra");

module.exports = override(useBabelRc());Code language: JavaScript (javascript)

Our last step here is to bypass Create React App’s stock scripts and use the ones supplied by react-app-rewired.

// package.json

{
  "name": "i18n-demo",
  "version": "0.1.0",
  "private": true,
  "dependencies": {
    // ...
  },
  "scripts": {
-   "start": "react-scripts start",
+   "start": "react-app-rewired start",
-   "build": "react-scripts build",
+   "build": "react-app-rewired build",
-   "test": "react-scripts test",
+   "test": "react-app-rewired test",
    "eject": "react-scripts eject",
    "extract": "formatjs extract 'src/**/*.js*' --out-file src/lang/en-US.json --ignore 'src/lang/**/*' --format simple --id-interpolation-pattern '[sha512:contenthash:base64:6]'"
  },
  // ...
}Code language: Diff (diff)

Now when we run our app, FormatJS runs its hash algorithm to re-generate its IDs, and our app works without errors.

🤿 Go deeper » We can also compile messages before distributing our app. This has a few benefits, including potentially speeding up our app. Read the FormatJS Message Distribution for more info on compiling.

Streamlining our localization process allows us to focus on the creative code of our app. With one command, we can create a translation source file to hand off to our translators, who can in turn return translations in ten languages, or a hundred.

And instead of maintaining somewhat cryptic message IDs, defaultMessages tell us exactly what a string in our UI says and means.

// In our components

<a href="/new-ingredient">
  {/* Default messages make UI strings clear,
      and not maintaining IDs or translation files
      means we work more quickly. */}
  <FormattedMessage defaultMessage="Add ingredient" />
</a>Code language: JavaScript (javascript)

How do I integrate react-intl with Phrase Strings?

We can make our jobs, and those of our translators, even easier by adopting a software localization platform like Phrase Strings. A single CLI command can push our latest source translations to Phrase Strings, where our translators can use a powerful translation admin panel to translate our app into many locales. With another command, we can pull the updated translations into our app. This keeps us focused on building our app, and not on the localization.

Creating the Phrase Strings project

To integrate Phrase Strings into your app, you need to configure a new Phrase Strings project:

  1. Create a Phrase account (you can start for free).
  2. Login, open Phrase Strings, and click the New Project button near the top of the screen to create a project.
  3. Configure the project to use the React Intl Simple translation file format
  4. Add starting languages. In our case, we can add en-US first as the default locale, then add ar-EG.
  5. Generate an access token from your profile page. (Click the user avatar near the top-right of the screen → Settings → ProfileAccess tokensGenerate Token). Make a copy of this token somewhere safe.
  6. Open your new project and go to More Settings. Check Enable ICU Message format support and click Save.
Creating a project in Phrase Strings | Phrase
Creating a project in Phrase Strings

Setting up the Phrase Strings CLI

That’s it for the Phrase Strings project config. Now let’s set up the Phrase Strings CLI so we can automate the transfer of our translation files to and from our translators.

🔗 Resource » Installation instructions for the Phrase Strings CLI depend on your platform (Windows, macOS, Linux). Just follow the CLI installation docs and you should be good to go.

CLI installed, let’s use it to connect our React project to Phrase Strings. From our React project root, let’s run the following command from the command line.

phrase initCode language: Bash (bash)

We’ll be asked to provide the access token we generated in Step 5 above. Let’s paste it in.

We’ll then be given some prompts:

  1. Select project—Select the Phrase Strings project we created above.
  2. Select the format to use for language files you download from Phrase Strings—Hit Enter to select the project’s default.
  3. Enter the path to the language file you want to upload to Phrase—Enter ./src/lang/en-US.json, since that’s our source translation file.
  4. Enter the path to which to download language files from Phrase—Enter ./src/lang/<locale_name>.json. (<locale_name> is a placeholder here, and it allows us to download all the translation files for our project: en-US, ar-EG, etc.).
  5. Do you want to upload your locales now for the first time?—Hit Enter to accept and upload.

🗒️ Note » A .phrase.yml is created in our project to save the config we created above. It will be used by default when we run commands like phrase push or phrase pull in our project.

At this point, our en-US.json file will get uploaded to Phrase, where our translators can use the powerful Phrase web admin to translate our app.

Phrase Strings translation editor | Phrase
Translation can be a breeze with Phrase’s translation memory and machine translation

When our translators are finished, we can run the following command to pull all of their work into our project.

phrase pullCode language: Bash (bash)

Our en-US.json and ar-EG.json files should now have the latest updates from our translators. We can simply run our app as normal to test our updated translations. Whether you have two locales, or two hundred, you only need to run two commands to keep your translations in sync. And your translators get an excellent environment to work in.

Adding a new language

Let’s demonstrate how much easier our lives are now with this new workflow. We’ll add French (fr-FR) to our app. Here are the steps:

  1. Tell our translators we want to add fr-FR to our app. (They’ll manage all that in Phrase Strings).
  2. Add French in our locales config object (see i18n-config.js listing below).
  3. Pull the new French translators from Phrase using phrase pull.

That’s literally it!

Here’s the updated i18n-config.js:

// src/i18n/i18n-config.js

export const defaultLocale = "en-US";

export const locales = {
  "en-US": {
    name: "English",
    dir: "ltr",
  },
  "ar-EG": {
    name: "Arabic (العربية)",
    dir: "rtl",
  },
+ "fr-FR": {
+   name: "French (Français)",
+   dir: "ltr",
+ },
};Code language: Diff (diff)

And here’s our app in French.

Screen of an app localized in French  | Phrase
Our translators used Phrase Strings to add French—we just used phrase pull to pull in the new translations

🔗 Resource » The app with French added is in the i18n-demo-extraction directory of our GitHub repo.

How do I localize my React TypeScript app with react-intl?

Using react-intl with TypeScript in a React app is relatively straightforward. An in-depth guide for this is outside the scope of this tutorial. We’ll cover strongly typing message IDs and we’ll share our complete solution code for your perusal.

In fact, most of react-intl will work with TypeScript out of the box. If you want to make your message IDs typed only to your keys, you can add the following code somewhere in your project.

// Your path may change here. Import the source
// translation file.
import messages from "./src/lang/en-US.json";

declare global {
  namespace FormatjsIntl {
    interface Message {
      ids: keyof typeof messages;
    }
  }
}Code language: TypeScript (typescript)

That’s basically it for react-intl TypeScript integration. Most of our other TypeScript considerations are specific to our demo.

🤿 Go deeper » You can find a diff of our TypeScript i18n solution in our GitHub repo. The entire TypeScript i18n demo is in an i18n-demo-typescript folder in the repo as well.

Heads up » We used TypeScript 4.9.5, since TypeScript 5 was giving us peer dependency errors when we tried installing it in our project. (--forceing the version 5 install seemed to work fine, but use at your own risk).

🔗 Resource » If you’re working with TypeScript and want to use the extraction/auto-generated ID workflow (optionally with Phrase), check out this diff. An entire Typescript extraction demo is in a dedicated i18n-demo-typescript-extraction folder.

Take React localization to the next level

We hope you found this guide to localizing React apps with react-intl/FormatJS enjoyable and informative. If you’re interested in localizing a Next.js app, check out our tutorial on Next.js internationalization. If you prefer using i18next, our guide to React localization with i18next should have you covered.

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.

Speak with an expert

Want to learn how our solutions can help you unlock global opportunity? We’d be happy to show you around the Phrase Localization Suite and answer any questions you may have.

Book a call