Software localization

Gatsby i18n: A Hands-on Guide

Hit the ground running quickly with this Gatsby i18n tutorial and learn how to add internationalization support to your Gatsby project.
Software localization blog category featured image | Phrase

Let’s face it, the modern React workflow can be a pain in the neck: We have routing, server-side rendering (SSR), static generation, and more. So we find ourselves tending towards a React-based framework that gives us all these functions out of the box. One of the first and most popular of these frameworks is Gatsby. While its architectural approach does have a learning curve, the framework’s maturity and large plugin ecosystem make it an attractive option among its ilk.

When it comes to internationalization (i18n), Gatsby’s robust official offerings can take some fiddling to get working (perhaps like all things Gatsby?). This guide aims to help with that: We’ll build a small demo app and localize it with the official Gatsby i18n theme and i18next, walking through the i18n step by step. Shall we?

Packages and versions used

The following NPM packages were used in the building of our demo app.

When we run gatsby new in a few minutes, the default Gatsby starter will install the following packages among others.

  • gatsby@4.12.1 ➞ all hail the great Gatsby
  • react@17.0.1 ➞ the ubiquitous UI library Gatsby is built on top of

We’ll add the following packages ourselves as we work through the article:

  • gatsby-plugin-mdx@3.12.1 ➞ integrates MDX Markdown with Gatsby
  • gatsby-theme-i18n@3.0.0 ➞ provides foundational i18n functionality for Gatsby apps
  • i18next@21.6.16 ➞ the popular i18n library provides UI string localization functions
  • react-i18next@11.16.8 ➞ provides components and hooks to easily integrate i18next with React apps
  • i18next-phrase-in-context-editor-post-processor@1.3.0 ➞ adds support for the Phrase In-Context Editor (optional, covered below)

🔗 Resource » See the package.json file in the accompanying GitHub repo for all package dependencies.

To begin (the starter app)

Our small demo app, the fictional fslivre, is a blog that celebrates the writing of F. Scott Fitzgerald.

Our demo app before localization

Let’s quickly run through how to build this puppy.

🔗 Resource » You can get the code for the app before localization from the start branch of our accompanying GitHub repo.

With the Gatsby CLI installed, we can run the following in our command line to spin up our app.

$ gatsby new fslivre

This should yield a fresh Gatsby app with the official default starter packages in place.

Building the blog

A simple file-based MDX structure will do the trick for our little blog. To work with MDX files we need to install the official Gatsby MDX plugin by running the following from the command line.

$ npm install gatsby-plugin-mdx

As usual, we need to add the MDX plugin to gatsby-config.js to complete its setup.

module.exports = {

  // …

  plugins: [

    `gatsby-plugin-react-helmet`,

    `gatsby-plugin-image`,

    `gatsby-transformer-sharp`,

    `gatsby-plugin-sharp`,

    `gatsby-plugin-postcss`,

    `gatsby-plugin-mdx`,

    // …

  ],

}

We can now add our blog content as MDX files, and pull it into our pages with GraphQL. Let’s organize our files:

.

├── blog/

│   ├── courage-after-greatness/

│   │   ├── cover.jpg

│   │   └── index.mdx

│   ├── the-other-side-of-paradise/

│   │   ├── cover.jpg

│   │   └── index.mdx

│   └── …

└── src/

    └── pages/

        ├── blog/

        │   └── {mdx.frontmatter__slug}.js

        └── index.js

Our MDX files are standard-fare Markdown with front matter. Note the slug field in the front matter, as we’ll use it for our dynamic routing in a minute.

---

title: Courage After Greatness

slug: courage-after-greatness

published_at: "2022-04-11"

hero_image:

  image: "./cover.jpg"

  alt: "Cover of book: Tender is the Night"

---

Fitzgerald began the novel in 1925 after the publication of his third novel _The Great Gatsby_…

Our home page, at the root route /, can query all the MDX files and present them as post teasers.

import * as React from "react"

import { graphql } from "gatsby"

import { getImage } from "gatsby-plugin-image"

import Seo from "../components/seo"

import Teaser from "../components/teaser"

import Layout from "../components/layout"

export const query = graphql`

  query BlogPosts {

    allMdx {

      nodes {

        frontmatter {

          hero_image {

            image {

              childImageSharp {

                gatsbyImageData(

                  width: 150

                  placeholder: BLURRED

                  formats: [AUTO, WEBP, AVIF]

                )

              }

            }

            alt

          }

          slug

          title

          published_at

        }

        id

        excerpt(pruneLength: 150)

      }

    }

  }

`

const IndexPage = ({ data }) => (

  <Layout>

    <Seo title="Home" />

    <h2>Recent writing</h2>

    {data.allMdx.nodes.map(post => {

      const image = getImage(post.frontmatter.hero_image.image)

      return <Teaser key={post.id} post={post} image={image} />

    })}

  </Layout>

)

export default IndexPage

Our Teaser component is largely presentational, so we’ll skip much of it here for brevity. Importantly, however, the header of each post in a Teaser links to a page with the full body of the post.

// …

export default function Teaser({ post, image }) {

  return (

    <article>

      // Image rendering code omitted for brevity

      <div>

        <h3>

          <Link to={`blog/${post.frontmatter.slug}`}>

            {post.frontmatter.title}

          </Link>

        </h3>

        <p>

          Published {post.frontmatter.published_at}

        </p>

        <p>{post.excerpt}</p>

      </div>

    </article>

  )

}

🤿 Go deeper » You can get the full code of the Teaser component from GitHub.

The dynamic <Link> is handled by Gatsby’s routing system so that the route /blog/my-frontmatter-slug automatically hits our page, src/pages/blog/{mdx.frontmatter__slug}.js. This page attempts to find a blog by the given slug and displays its contents.

🤿 Go deeper » We’re using Gatsby’s file system route API to map our front matter slugs to our route.

import * as React from "react"

import { graphql } from "gatsby"

import { MDXRenderer } from "gatsby-plugin-mdx"

import Seo from "../../components/seo"

import Layout from "../../components/layout"

export const query = graphql`

  query PostBySlug($frontmatter__slug: String) {

    mdx(frontmatter: { slug: { eq: $frontmatter__slug } }) {

      frontmatter {

        title

        published_at

        # …

      }

      body

    }

  }

`

const BlogPost = ({ data }) => {

  const post = data.mdx

  return (

    <Layout>

      <Seo title={post.frontmatter.title} />

      <h1>{post.frontmatter.title}</h1>

      <p>

        Published at {post.frontmatter.published_at}

      </p>

      // Image rendering code omitted for brevity

      <article>

        <MDXRenderer>{post.body}</MDXRenderer>

      </article>

    </Layout>

  )

}

export default BlogPost

So when a visitor hits the route /blog/courage-after-greatness, the post with the slug courage-after-greatness is loaded, for example.

The standalone blog post page

We have a basic blog structure working, but alas, it’s all English. What if we want our site presented in other languages and locales? Well, we internationalize and localize, of course. Let’s get cooking.

🔗 Resource » You can get the entire code for our starter app from the start branch of our accompanying GitHub repo.

Localizing with the official Gatsby i18n theme

Let’s start by localizing our routes and blog content. We’ll localize our site to Arabic here, but you can add any language(s) of your choice. Luckily, the official gatsby-theme-i18n handles route and MDX content localization for us, so let’s set it up.

Installing and configuring the theme

We’ll install the theme via NPM from the command line:

$ npm install gatsby-theme-i18n

Next, we’ll configure the theme in our gatsby-config.js.

module.exports = {

  // …

  plugins: [

    // …

    {

      resolve: `gatsby-theme-i18n`,

      options: {

        defaultLang: `en`,

        configPath: require.resolve(`./i18n/config.json`),

      },

    },

  ],

}

The defaultLang will be the one used for our unlocalized routes, like / or /about. Other languages will require a route prefix, like /ar or /ar/about.

🤿 Go deeper » All the gatsby-theme-i18n configuration options are listed in the official docs.

Note the configPath above: It points to a configuration file that defines our app’s supported locales. Let’s create this file now.

[

  {

    "code": "en",             // ISO 639-1 language code

    "hrefLang": "en-CA",      // Used for <html lang> attribute

    "name": "English",        // Human-readable name

    "localName": "English",   // Name in the language itself

    "langDir": "ltr"          // Layout direction for language

  },

  {

    "code": "ar",

    "hrefLang": "ar-EG",

    "name": "Arabic",

    "localName": "عربي",

    "langDir": "rtl"

  }

]

We can add as many entries to the array in config.json as we want; each should correspond to a unique locale our app supports and should have a unique two-letter ISO 639-1 code.

🗒 Note » The configuration data in config.json can be accessed via GraphQL from the themeI18N type. We’ll access this data indirectly via a built-in useLocalization() hook in a moment.

The theme ought to be configured at this point. We can test it by restarting our Gatsby development server and adding the following code to one of our components.

// …

import { useLocalization } from "gatsby-theme-i18n"

const IndexPage = ({ data }) => {

  const { locale, defaultLang, config } = useLocalization()

  return (

    <Layout>

      <Seo title="Home" />

      <p>Current locale: {locale}</p>

      <p>Default locale: {defaultLang}</p>

      <pre>{JSON.stringify(config, null, 2)}</pre>

     // ...

    </Layout>

  )

}

export default IndexPage

If we visit our root route /, we should see that Gatsby has en (English) as the active locale. This is because we configured en to be the default language earlier.

Locale output for /

Hitting /ar, however, yields ar (Arabic) as the active locale.

Locale output for /ar

This is wonderful since it saves us from mucking about with the routing system just to get localized routes. Next, we’ll use this newfound power to localize our blog MDX posts.

Localizing content

The Gatsby i18n theme will automatically recognize MDX files that are prefixed or suffixed with locale codes and assign locales to them. So a file like index.ar.mdx will be assigned the ar locale, for example.

Let’s update our blog content to use this. We can rename each of our blog index.mdx files to index.en.mdx and add an index.ar.mdx alongside it.

Before:

.

└── blog/

    ├── courage-after-greatness/

    │   ├── cover.jpg

    │   └── index.mdx

    ├── the-other-side-of-paradise/

    │   ├── cover.jpg

    │   └── index.mdx

    └── …

After:

.

└── blog/

    ├── courage-after-greatness/

    │   ├── cover.jpg

    │   ├── index.ar.mdx

    │   └── index.en.mdx

    ├── the-other-side-of-paradise/

    │   ├── cover.jpg

    │   ├── index.ar.mdx

    │   └── index.en.mdx

    └── …

Of course, the contents of each index.ar.mdx file should be localized to Arabic.

---

title: الشجاعة بعد العظمة

slug: courage-after-greatness

published_at: "2022-04-11"

hero_image:

  image: "./cover.jpg"

  alt: "غلاف الكتاب: الليل حنون"

---

بدأ فيتزجيرالد الرواية عام 1925 بعد نشر روايته الثالثة "غاتسبي العظيم". خلال عملية الكتابة المطولة ، تدهورت الصحة العقلية لزوجته زيلدا فيتزجيرالد ، وتطلبت دخول المستشفى لفترة طويلة بسبب ميولها الانتحارية والقتل. بعد دخولها المستشفى في بالتيمور بولاية ماريلاند ، استأجر المؤلف عقار La Paix في ضاحية توسون ليكون قريبًا من زوجته ، وواصل العمل على المخطوطة. — [ويكيبيديا](https://en.wikipedia.org/wiki/Tender_Is_the_Night)

Heads up » We’re sharing the same front matter slug value across en.mdx and ar.mdx versions. This way we can use the slug to query for a single post as we’ve done before, and further filter by locale to get the en or ar version of that post. We’ll see this in action soon.

Updating our GraphQL queries

The Gatsby i18n theme will add the active locale as a variable named locale to each page’s context, making it available to our GraphQL queries.

A locale field will also be added to the mdx GraphQL type and will be equal to the locale in the corresponding file’s suffix. So an MDX file with the suffix ar.mdx will have a node with {fields{locale: {eq: "ar"}}}.

We can use these two new additions in tandem to filter our blog index list by the active locale.

// ...

export const query = graphql`

  query BlogPosts($locale: String) {

    allMdx(filter: { fields: { locale: { eq: $locale } } }) {

      nodes {

        frontmatter {

          hero_image {

            # Image query omitted for brevity

          }

          slug

          title

          published_at

        }

        id

        # Add `truncate: true` to enable pruning for

        # non-ascii languages

        excerpt(pruneLength: 150, truncate: true)

      }

    }

  }

`

// Component rendering is the same as before

const IndexPage = ({ data }) => {

  return (

    <Layout>

      <Seo title="Home" />

      <h2>Recent writing</h2>

      {data.allMdx.nodes.map(post => {

        const image = getImage(post.frontmatter.hero_image.image)

        return <Teaser key={post.id} post={post} image={image} />

      })}

    </Layout>

  )

}

export default IndexPage

And with that, we now have localized content on our index page. When we visit /, our app looks the same as before, but when we visit /ar, we see our Arabic content.

Our blog index with Arabic content

Our standalone {mdx.frontmatter__slug}.js page will need a similar update to its query to show localized content. The main difference here is that we’re filtering by both the locale and the front-matter slug. These will map to the corresponding route like /{locale}/blog/{frontmatter__slug}.

// …

export const query = graphql`

  query PostBySlug(

    $frontmatter__slug: String,

    $locale: String

  ) {

    mdx(

      frontmatter: { slug: { eq: $frontmatter__slug } }

      fields: { locale: { eq: $locale } }

    ) {

      frontmatter {

        title

        published_at

        hero_image {

          # Image query omitted for brevity

        }

      }

      body

    }

  }

`

// Component rendering is the same as before

const BlogPost = ({ data }) => {

  const post = data.mdx

  return (

    <Layout>

      <Seo title={post.frontmatter.title} />

      <h1>{post.frontmatter.title}</h1>

      <p>

        Published at {post.frontmatter.published_at}

      </p>

      // …

      <article>

        <MDXRenderer>{post.body}</MDXRenderer>

      </article>

    </Layout>

  )

}

export default BlogPost

Localized links

If we were to manually navigate to a blog post by typing, say, /ar/blog/courage-after-greatness into our browser’s address bar, we would get this post’s Arabic version. The links on the index page, however, will currently point to the default English versions of blog posts: None of the links on our site are locale-aware.

An easy fix for this is swapping Gatsby’s Link component with the LocalizedLink component from gatsby-theme-i18n. The latter will add the locale URI prefix to links for us.

// Given that the active locale is "ar"

<LocalizedLink to="about">About</LocalizedLink>

// renders to => <a href="/ar/about">About</a>

Of course, LocalizedLink will omit the prefix for the default en locale. Additionally, the component is just a wrapper around Gatsby’s Link, so it will inherit the latter’s performance benefits without any extra work by us.

Generally speaking, we’ll want to go through and replace every occurrence of <Link> with <LocalizedLink> in our app.

// …

// Remove the next line:

import { Link } from 'gatsby'

// Use this instead:

import { LocalizedLink as Link } from "gatsby-theme-i18n"

export default function Teaser({ post, image }) {

  return (

    <article>

      // …

      <div>

        <h3>

          <Link to={`/blog/${post.frontmatter.slug}`}>

            {post.frontmatter.title}

          </Link>

        </h3>

        <p>

          Published {post.frontmatter.published_at}

        </p>

        <p>{post.excerpt}</p>

      </div>

    </article>

  )

}

Now when we click on a blog post from our Arabic index, we land on the Arabic post.

A standalone post localized to Arabic

Heads up » Links within rendered Markdown from MDX posts won’t be localized by default. Luckily, gatsby-theme-i18n provides an MdxLink component that can help with that. Take a look at the official docs to learn how to use it.

Localizing UI with i18next

So far we have localized routing and content, but what about the strings in our React components and pages? It would certainly look odd if we left those unlocalized. We can take care of this with the popular i18next library, which has first-class React support. Luckily for us, Gatsby is built on React, so we can make use of i18next to internationalize our demo app.

🤿 Go deeper » A Guide to React Localization with i18next covers more i18next + React topics, so take a look there if you feel we’ve missed something in this article.

Setup: installation and creating a custom plugin

Let’s install i18next and create a small Gatsby wrapper plugin around it so that we can localize our pages and components during server-side rendering and on the browser.

🗒 Note » An official i18next add-on theme can be added to the Gatsby i18n theme we’re currently using, and it does the integration we’re about to do here for you. However, if you want to modify the i18next instance during initialization—say to add i18next plugins—you might not be able to with the official add-on theme. For maximum flexibility, we’ll be going the manual route here.

Installing the libraries

We’ll want both the core i18next library and the react-i18next extension framework. Let’s install them through NPM.

$ npm install i18next react-i18next

Creating the plugin

OK, with the packages installed, let’s write a custom Gatsby plugin to bootstrap an i18next instance and provide it to all our pages. Here’s what our plugin file structure will look like:

.

├── blog/

│   └── …

├── src/

│   └── …

├── i18n/

│   ├── …

│   └── l10n/

│       ├── ar/

│       │   └── translation.json

│       └── en/

│           └── translation.json

└── plugins/

    ├── gatsby-theme-i18n-i18next-wrapper/

    │   ├── gatsby-browser.js

    │   ├── gatsby-node.js

    │   ├── gatsby-ssr.js

    │   ├── index.js

    │   └── package.json

    └── src/

        └── wrap-page-element.js

We’ll put our translations in per-locale files that look like the following:

{

  "about": "About us",

  "app_description": "A blog dedicated to F. Scott Fitzgerald",

  "app_name": "fslivre",

  "articles": "Articles",

  // …

}

{

  "about": "نبذة",

  "app_description": "مدونة مخصصة لأف سكوت فتزجيرلد",

  "app_name": "أف أس ليفر",

  "articles": "مقالات",

  // …

}

In our plugin, we’ll determine the active locale and feed that locale’s translation JSON to i18next. We’ll then be able to dynamically load translations by key via i18next.t(). Here’s an example of how we'll use the solution:

// Given the above JSON translation files

// In our component

import { useTranslation } from "react-i18next"

const IndexPage = () => {

  const { t } = useTranslation()

  return <h2>{t("articles")}</h2>

}

// When active locale is "en", our component renders to:

<h2>Articles</h2>

// When active locale is "ar", we get:

<h2>مقالات</h2>

We’ll see this in action after we finish our setup. Now let’s go through our plugin files, starting with gatsby-node.js, which Gatsby will load automatically while it’s building our site, calling any exported Gatsby Node APIs from the file.

const path = require(`path`)

// …

let absoluteLocalesDirectory

// Called during Gatsby execution, runs as soon as plugins are loaded.

exports.onPreInit = ({ store }, { locales }) => {

  // …

  // Get the absolute path to the locales directory

  absoluteLocalesDirectory = path.join(

    store.getState().program.directory,

    locales

  )

}

// Let plugins extend/mutate the site’s webpack configuration.

exports.onCreateWebpackConfig = ({ actions, plugins }) => {

  // Expose the absolute path to the locale directory as

  // a global variable.

  actions.setWebpackConfig({

    plugins: [

      plugins.define({

        GATSBY_THEME_I18N_I18NEXT_WRAPPER: JSON.stringify(

          absoluteLocalesDirectory

        ),

      }),

    ],

  })

}

gatsby-node.js exposes a GATSBY_THEME_I18N_I18NEXT_WRAPPER global variable which holds the absolute path to our locale JSON parent directory. We can now use this variable during SSR and on the browser to initialize an i18next instance. We'll do that via a shared isomorphic wrapPageElement function.

export { wrapPageElement } from "./src/wrap-page-element"

export { wrapPageElement } from "./src/wrap-page-element"

Both our gatsby-ssr.js and gatsby-browser.js files are loaded and used by Gatsby during SSR and on the browser, respectively. The files export the shared wrapPageElement function which Gatsby will call automatically to allow our plugin to wrap the React component of the currently requested page before rendering it.

wrapPageElement is a good place for creating an i18next instance and injecting it into an I18nextProvider that wraps each of our pages. So where is this elusive function, you ask? Coming right up.

🔗 Resource » Read the wrapPageElement documentation for the Gatsby SSR and Gatsby browser APIs.

/* global GATSBY_THEME_I18N_I18NEXT_WRAPPER */

// 👆Supress ESLINT error regarding global variables

import * as React from "react"

import i18next from "i18next"

import { I18nextProvider } from "react-i18next"

const wrapPageElement = ({ element, props }, options) => {

  // The Gatsby I18n plugin we installed earlier will add

  // the active locale to our page context

  const currentLocale = props.pageContext.locale

  // Use the variable exposed by gatsby-node.js to find

  // the JSON file corresponding to the active locale e.g.

  // /absolute/path/to/i18n/l10n/ar/translation.json

  // when active locale is "ar"

  const translation = require(`${GATSBY_THEME_I18N_I18NEXT_WRAPPER}/${currentLocale}/translation.json`)

  // Initialize the i18next instance

  i18next.init({

    lng: currentLocale,

    // Load translations for the active locale

    resources: { [currentLocale]: { translation } },

    fallbackLng: "en",

    // Make init() run synchronously, ensuring that

    // resources/translations are loaded as soon as init()

    // finishes (default behaviour is async loading)

    initImmediate: false,

    // Output useful logs to the browser console

    debug: process.env.NODE_ENV === "development",

    // Disable escaping for cross-site scripting (XSS)

    // protection, since React does this for us

    interpolation: { escapeValue: false },

  })

  // Wrap 🌯

  return <I18nextProvider i18n={i18next}>{element}</I18nextProvider>

}

export { wrapPageElement }

Our shared wrapPageEement function ensures that an i18next instance, including the translations for the active locale, is available to each of our Gatsby pages.

🤿 Go deeper » Check out all of i18next’s config options and get more details on the I18nextProvider in the official documentation.

🔗 Resource » Our plugin code is largely based on the official Gatsby i18next add-on theme. You can peruse the theme’s code on GitHub.

Configuring our plugin

The final step in wiring up i18next to our Gatsby project is registering and configuring our shiny new plugin in gastby-config.js.

module.exports = {

  // …

  plugins: [

    `gatsby-plugin-react-helmet`,

    // …

    {

      resolve: `gatsby-theme-i18n`,

      options: {

        defaultLang: `en`,

        configPath: require.resolve(`./i18n/config.json`),

      },

    },

    {

      // Gatsby will automatically resolve from the

      // /plugins directory

      resolve: `gatsby-theme-i18n-i18next-wrapper`,

      options: {

        // Provide the relative path to our translation files

        // to our plugin

        locales: `./i18n/l10n`,

      },

    },

  ],

}

🔗 Resource » Get the code for our little custom plugin from GitHub.

Heads up » When developing with gatsby develop you might get an error saying “Warning: Cannot update a component (Header) while rendering a different component (PageRenderer),” in your browser console. This is a known issue at time of writing, and doesn’t seem to break the app. Thankfully, the issue disappears entirely in production builds. If you have an update on this issue, please let us know in the comments below.

🗒 Note » A warning reading “i18next: init: i18next is already initialized. You should call init just once!” might appear in the browser console when you have debug: true in the i18next.init() options. This didn’t break anything in my testing and the warning disappeared entirely with debug: false.

That should do it for setting up and connecting i18next to our app. How about we start using it to localize our UI?

Basic translation

Say we have the following translations in our JSON.

{

  "about": "About us",

  "articles": "Articles",

  // …

}

{

  "about": "نبذة",

  "articles": "مقالات",

  // …

}

Our local plugin, along with the Gatsby i18n plugin, will have ensured that if we hit the / route, the en/translation.json values will be loaded. And if we hit /ar the ar/translation.json values will be loaded. In our components, we just need to use the useTranslation hook to get the active locale’s translations.

import * as React from "react"

import { useTranslation } from "react-i18next"

import { LocalizedLink } from "gatsby-theme-i18n"

const Header = ({ siteTitle }) => {

  const { t } = useTranslation()

  return (

    <header>

      <div>

        <div>

          // …

          <nav>

            <ul>

              <li>

                <LocalizedLink to="/">

                  {t("articles")}

                </LocalizedLink>

              </li>

              <li>

                <LocalizedLink>

                  {t("about")}

                </LocalizedLink>

              </li>

            </ul>

          </nav>

        </div>

        <LanguageSwitcher />

      </div>

    </header>

  )

}

export default Header

The t() function retrieves the active locale translation by key.

Our app will show localized strings dynamically depending on the active locale

Interpolation

To inject dynamic values, we use a {{placeholder}} in our message and pass a map with a corresponding key as a second parameter to t().

// In our translation file

{

  "user_greeting": "Hello {{username}}!"

}

// In our component

<p>{t("user_greeting", {username: "Adam"})}</p>

// Renders =>

<p>Hello, Adam!</p>

Plurals

We define plural forms in our translation files and use a special interpolated count variable to choose the correct form.

{

  // …

  "articles_published_one": "{{count}} article",

  "articles_published_other": "{{count}} articles"

}

{

  // …

  // Arabic has six plural forms

  "articles_published_zero": "لم تنشر مقالات بعد",

  "articles_published_one": "مقال {{count}}",

  "articles_published_two": "مقالان",

  "articles_published_few": "{{count}} مقالات",

  "articles_published_many": "{{count}} مقال",

  "articles_published_other": "{{count}} مقال"

}

Note the special suffixes above (_zero, _one, etc.). These correspond to the locale’s plural form as returned by the JavaScript standard Intl.PluralRules.select()  method, which i18next uses under the hood.

In our components, we call t() using the key without any suffix and passing in a count integer.

<p>{t("articles_published", {count: 2})}</p>

// When active locale is English =>

<p>2 articles</p>

// When active locale is Arabic =>

<p>مقالان</p>

🤿 Go deeper » There’s a lot to React i18next localization that we couldn’t cover here: date and number formatting, right-to-left layouts, localizing page titles, to name a few. Our Guide to React Localization with i18next should fill in many of these gaps for you. Additionally, The Ultimate Guide to JavaScript Localization is an expansive resource that covers a lot of i18n topics across an array of JavaScript libraries.

A language switcher

Remember the Gatsby i18n plugin we set up earlier? Well, it’s the source of truth for both configured supported locales and the active locale. Let’s use these values and control a <select> element to provide our users with a language-switching UI.

import * as React from "react"

import { navigate } from "gatsby"

import { useLocalization } from "gatsby-theme-i18n"

// …

function LanguageSwitcher() {

  const { locale, defaultLang, config } = useLocalization()

  const switchLanguage = e => {

    // Avoid an unnecessary page load if the

    // user selected the already active locale

    if (e.target.value === locale) {

      return

    }

    // Go to the home page corresponding to the

    // selected locale

    if (e.target.value === defaultLang) {

      navigate("/")

    } else {

      navigate(`/${e.target.value}`)

    }

  }

  return (

    <div>

      // …

      <select

        value={locale}

        onChange={switchLanguage}

      >

        {config.map(c => (

          <option key={c.code} value={c.code}>

            {c.localName} ({c.name})

          </option>

        ))}

      </select>

    </div>

  )

}

export default LanguageSwitcher

If we tuck our <LanguageSwitcher> into our <Header> component, we get a saucy little switcher.

Our UI allows our visitors to select the language of their choice

🔗 Resource » Get the code for the app we’ve built from GitHub.

Using the Phrase in-context editor

Gatsby can be a bit configuration-heavy, but its structured architecture is well-suited for large-scale sites with bigger teams working on them. A reliable partner for scaling your app localization is Phrase. With its myriad features for product managers, translators, developers, and designers, Phrase takes care of the heavy lifting of software localization at scale and keeps your team focused on your product.

Phrase comes with an in-context editor (ICE) that allows translators to simply browse your site and edit along the way. This avoids a lot of confusion regarding the context around a translation, improving translation quality. The ICE has built-in support for react-i18next, which means we can plug it into our Gatsby sites that are using react-i18next.

The Phrase In-Context Editor used to translate strings in our demo app

🤿 Go deeper » The Phrase ICE can be used with vanilla JS, Vue, React, Next.js, among others. Check out the documentation for all the details.

At this point, I assume you're a Phrase user (get a free trial if you aren't), you’ve created a project in Phrase and connected it with your app using the Phrase command-line client. If you’re following along with us here and want the .phrase.yml we’re using, here’s the listing:

phrase:

  access_token: your_access_token

  project_id: your_project_id

  push:

    sources:

    - file: ./i18n/l10n/<locale_name>/translation.json

      params:

        file_format: i18next

  pull:

    targets:

    - file: ./i18n/l10n/<locale_name>/translation.json

      params:

        file_format: i18next

Installing the ICE

The Phrase in-context editor comes as an i18next post-processor and can be installed via NPM.

$ npm install i18next-phrase-in-context-editor-post-processor

With the package in place, we can revisit our custom i18next wrapper plugin to add the ICE to i18next.

// …

import PhraseInContextEditorPostProcessor from "i18next-phrase-in-context-editor-post-processor"

const wrapPageElement = ({ element, props }, options) => {

  // …

  i18next

    .use(

      new PhraseInContextEditorPostProcessor({

        phraseEnabled: true,

        projectId: your_project_id,

        // Match the default i18next placeholder syntax:

        prefix: "{{",

        suffix: "}}",

      })

    )

    .init({

      lng: currentLocale,

      resources: { [currentLocale]: { translation } },

      fallbackLng: "en",

      initImmediate: false,

      debug: process.env.NODE_ENV === "development",

      interpolation: {

        escapeValue: false,

      },

      // Register the plugin as a post-processor

      postProcess: ["phraseInContextEditor"],

    })

  return <I18nextProvider i18n={i18next}>{element}</I18nextProvider>

}

export { wrapPageElement }

That’s it. If we restart our Gatsby development server, we should be greeted by a Phrase login modal.

The Phrase ICE login modal

We can now log in to Phrase and begin translating on-the-fly.

Using the Phrase ICE to translate in place

Heads up » At the time of writing, there were some issues when using the Phrase ICE with Firefox; these weren’t showstoppers, and I could still translate and save, but I generally found the experience smoother on Chrome-based browsers and Safari.

Saving a translation in the ICE will automatically update it in our Phrase project. When our translators are happy with their translations and want us, developers, to commit them to the Gatsby project, we just do a pull with the Phrase CLI.

$ phrase pull

This will update our /en/translation.json and /ar/translation.json files, which makes our new translations ready to go to production.

🤿 Go deeper » Peruse the documentation for the Phrase In-Context Editor for all the configuration options and more.

🔗 Resource » Get all the code for the app we built above, along with the Phrase ICE integration, from GitHub.

Closing up

We hope you’ve enjoyed our little run through Gatsby i18n. Until next time, keep your hands on the keyboard and your coffee warm. Cheers 😊