Why i18n Matters—and Where Gatsby Gets Tricky

Internationalization (i18n) means adapting content so it works across languages, regions, and cultures. It matters because the web is far more English-dominated than the people using it: roughly 60.4% of online content is in English, while only about 16.2% of the world speaks it. Until machine translation becomes flawless, developers have to localize their sites themselves.

Implementing i18n in Gatsby typically introduces two distinct problems:

  1. Storing and retrieving translated content. You need files to hold translations without inflating your JS bundle, plus a way to fetch the right translation for each page.
  2. Routing localized content. Users need to land on a language-specific URL, like my-site.com/es, and Gatsby has to generate a page for every locale.

Since Gatsby is a static site generator, translations are served as static HTML rather than being loaded into the client bundle—so the first concern is less severe. The routing problem, however, has no single standard answer. The plugin ecosystem offers several options, each with different trade-offs around compatibility with the File System Route API and the createPages approach. This article walks through the leading candidates with a real codebase.

The Starting Point: A Recipe Blog

To evaluate plugins against a realistic scenario, we'll use a Gatsby site that generates pages from a content array—specifically, a cooking blog with markdown recipe files. The starting project is a plain JavaScript Gatsby install with no i18n plugins yet.

Two Gatsby plugins handle markdown ingestion: gatsby-source-filesystem and gatsby-transformer-remark. The blog keeps recipes in ./src/content/, one markdown file per post. Each file uses YAML frontmatter to store metadata like title, date, locale, and slug—and each post has a companion cover.jpg. To process those images, the project also needs gatsby-plugin-image, gatsby-plugin-sharp, and gatsby-transformer-sharp.

Once the dev server is running, the GraphQL layer exposes the content. A query against the `markdownRemark` nodes returns the recipes' fields. The ./src/pages/index.js page runs that query and renders a RecipePreview component for each entry, pulling title, date, slug, and cover image from the query results.

Individual post pages are created with the File System Route API. In ./src/pages/recipes/, a file named {markdownRemark.frontmatter__slug}.js tells Gatsby to generate one page per recipe, using its slug as the URL. The page component queries for a specific markdown file by using the route API's injected context variable: the slug lands in the page context as frontmatter__slug, which the query references as $frontmatter__slug. The component renders the title, date, post body (injected via html), and the GatsbyImage component for the cover. Gatsby's Head API updates the document title to match the recipe.

Preparing Translated Content

Before testing i18n plugins, we add a Spanish version of each markdown file alongside the English ones. The folder structure becomes:

Each Spanish file keeps the same frontmatter fields but translated values, with the locale property changed to es. Critically, the slug must stay identical across locales so that corresponding English and Spanish posts share a route namespace.

First Candidate: gatsby-plugin-i18n

Gatsby's official localization documentation lists gatsby-plugin-i18n as its first recommended solution. The plugin's approach is filename-based: it generates localized routes by reading language codes from file names. For instance, ./src/pages/index.en.js produces a route at my-site.com/en/.

Despite that endorsement, this plugin is not a practical choice. It hasn't seen an update since 2019, and it breaks the File System Route API—forcing you to generate pages through the Gatsby Node API's createPages function instead. The result is unmanageable even at moderate scale: a 20-page site with five languages would require 100 separate files just to maintain the routes. The plugin's only feasible use case is adding localized routes to a handful of static pages, and even then, the effort does not justify the risk of using abandoned tooling in a production Gatsby project.

Routing All Locales With gatsby-theme-i18n

For a quicker setup than the previous plugin, gatsby-theme-i18n handles locale routing with minimal configuration. You'll still need gatsby-plugin-react-helmet and react-helmet for language metadata in the head:

npm install gatsby-theme-i18n gatsby-plugin-react-helmet react-helmet

Add the plugin to gatsby-config.js, pointing configPath to a JSON file that defines each locale:

// ./gatsby-config.js

module.exports = {
  //...
  plugins: [
    //other plugins ...
    {
      resolve: `gatsby-theme-i18n`,
      options: {
        defaultLang: `en`,
        prefixDefault: true,
        configPath: require.resolve(`./i18n/config.json`),
      },
    },
  ],
};

Create that file under a new ./i18n/ directory at the project root:

[
  {
    "code": "en",
    "hrefLang": "en-US",
    "name": "English",
    "localName": "English",
    "langDir": "ltr",
    "dateFormat": "MM/DD/YYYY"
  },

  {
    "code": "es",
    "hrefLang": "es-ES",
    "name": "Spanish",
    "localName": "Español",
    "langDir": "ltr",
    "dateFormat": "DD.MM.YYYY"
  }
]

Note: Restart the development server after editing gatsby-config.js.

With that, the plugin generates localized routes for every page. Visit http://localhost:8000/es/ or http://localhost:8000/en/ to see it in action.

Filtering Localized Content

The immediate problem is that both Spanish and English pages list all posts, since no locale filtering is applied. The plugin injects the current locale into each page's context as a $locale query variable. Update your page queries accordingly:

index page query:

query IndexQuery($locale: String) {
  allMarkdownRemark(filter: {frontmatter: {locale: {eq: $locale}}}) {
    nodes {
      frontmatter {
        slug
        title
        date
        cover_image {
          image {
            childImageSharp {
              gatsbyImageData
            }
          }
          alt
        }
      }
    }
  }
}

{markdownRemark.frontmatter__slug}.js page query:

query RecipeQuery($frontmatter__slug: String, $locale: String) {
  markdownRemark(frontmatter: {slug: {eq: $frontmatter__slug}, locale: {eq: $locale}}) {
    frontmatter {
      slug
      title
      date
      cover_image {
        image {
          childImageSharp {
            gatsbyImageData
          }
        }
        alt
      }
    }
    html
  }
}

All existing Gatsby links now direct users to non-localized routes, which lead to 404s. Replace the standard Link component with the LocalizedLink exported by gatsby-theme-i18n. It works like the original but points to the current locale:

// ./src/components/RecipePreview.js

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

//...

Switching Between Locales

A language selector needs the current page's path — e.g., from /en/recipes/pizza, extract recipes/pizza and prefix it with the target locale to get /es/recipes/pizza.

To access location data everywhere, use the wrapPageElement function in both gatsby-browser.js and gatsby-ssr.js. This exposes each page's props, including a location object. Create a context provider in ./src/context/ to pass that data down:

// ./src/context/LocationContext.js

import * as React from "react";
import {createContext} from "react";

export const LocationContext = createContext();

export const LocationProvider = ({location, children}) => {
  return <LocationContext.Provider value={location}>{children}</LocationContext.Provider>;
};

Pass the location object to the provider in both Gatsby files:

// ./gatsby-ssr.js & ./gatsby-browser.js

import * as React from "react";
import {LocationProvider} from "./src/context/LocationContext";

export const wrapPageElement = ({element, props}) => {
  const {location} = props;

  return <LocationProvider location={location}>{element}</LocationProvider>;
};

Note: Creating gatsby-ssr.js and gatsby-browser.js requires a development server restart.

The useLocalization hook from gatsby-theme-i18n gives access to the i18n config, but not the current locale when called from Gatsby's wrapper files. The wrapPageElement props argument, however, contains the page context with the locale. Set up another context for it:

// ./src/context/LocaleContext.js

import * as React from "react";
import {createContext} from "react";

export const LocaleContext = createContext();

export const LocaleProvider = ({locale, children}) => {
  return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;
};

Then wrap pages with it:

// ./gatsby-ssr.js & ./gatsby-browser.js

import * as React from "react";
import {LocationProvider} from "./src/context/LocationContext";
import {LocaleProvider} from "./src/context/LocaleContext";

export const wrapPageElement = ({element, props}) => {
  const {location} = props;
  const {locale} = element.props.pageContext;

  return (
    <LocationProvider location={location}>
      <LocaleProvider locale={locale}>{element}</LocaleProvider>
    </LocationProvider>
  );
};

To strip the locale prefix from the path, use a simple regex that matches /en/ or /es/ at the start:

/(\/e(s|n)|)(\/*|)/

Note: This regex is specific to the en and es pair; a different locale set needs an adjusted pattern.

Now create the language selector component:

// ./src/components/LanguageSelector

import * as React from "react";
import {useContext} from "react";
import {useLocalization} from "gatsby-theme-i18n";
import {Link} from "gatsby";
import {LocationContext} from "../context/LocationContext";
import {LocaleContext} from "../context/LocaleContext";

export const LanguageSelector = () => {
  const {config} = useLocalization();
  const locale = useContext(LocaleContext);
  const {pathname} = useContext(LocationContext);

  const removeLocalePath = /(\/e(s|n)|)(\/*|)/;
  const pathnameWithoutLocale = pathname.replace(removeLocalePath, "");

  return (
    <div>
      {config.map(({code, localName}) => {
        return (
          code !== locale && (
            <Link key={code} to={`/${code}/${pathnameWithoutLocale}`}>
              {localName}
            </Link>
          )
        );
      })}
    </div>
  );
};

Here's the flow inside it:

  1. Fetch the i18n config via useLocalization.
  2. Get the current locale from context.
  3. Get the pathname from context (e.g., /en/recipes/pizza).
  4. Strip the locale segment with the regex, leaving recipes/pizza.
  5. Render a link for each locale except the current one, using a standard Gatsby Link to the re-prefixed path.

Add the selector inside wrapPageElement in both wrapper files:

// ./gatsby-ssr.js & ./gatsby-browser.js

import * as React from "react";
import {LocationProvider} from "./src/context/LocationContext";
import {LocaleProvider} from "./src/context/LocaleContext";
import {LanguageSelector} from "./src/components/LanguageSelector";

export const wrapPageElement = ({element, props}) => {
  const {location} = props;
  const {locale} = element.props.pageContext;

  return (
    <LocationProvider location={location}>
      <LocaleProvider locale={locale}>
        <LanguageSelector />
        {element}
      </LocaleProvider>
    </LocationProvider>
  );
};

Redirecting Non-Localized Routes

Routes like / or /recipes/pizza are now empty. Use Gatsby redirects in gatsby-node.js to send users to a locale. Preference the user's accepted language based on the request's origin, defaulting to English:

// ./gatsby-node.js

exports.createPages = async ({actions}) => {
  const {createRedirect} = actions;

  createRedirect({
    fromPath: `/*`,
    toPath: `/en/*`,
    isPermanent: true,
  });

  createRedirect({
    fromPath: `/*`,
    toPath: `/es/*`,
    isPermanent: true,
    conditions: {
      language: [`es`],
    },
  });
};

Note: Redirects work only in production, not during local development.

The wildcard * preserves the rest of the path, so /recipes/mac-and-cheese/ becomes /en/recipes/mac-and-cheese/.

Formatting Content With react-intl

react-intl is an internationalization library that works in any React app, including Gatsby, without extra configuration. Beyond translation components, it handles number, date, and time formatting via FormattedNumber, FormattedDate, and FormattedTime.

The central IntlProvider receives three key attributes:

  • message — an object of translation strings.
  • locale — the current page locale.
  • defaultLocale — the fallback locale.

With a Spanish locale, the provider formats values accordingly:

  <IntlProvider messages={{}} locale="es" defaultLocale="en" >
      <FormattedNumber value={15000} />
      <br />
      <FormattedDate value={Date.now()} />
      <br />
      <FormattedTime value={Date.now()} />
      <br />
  </IntlProvider>,
15.000

23/1/2023

19:40

Switching the locale to en produces English-formatted output:

15,000

1/23/2023

7:42 PM

Using react-intl In Gatsby

Continuing with the gatsby-theme-i18n example, install the package:

npm i react-intl

Create a messages.js file in ./i18n/ with the translated strings for the index page's title and subtitle:

// ./i18n/messages.js

export const messages = {
  en: {
    index_page_title: "Welcome to my English cooking blog!",
    index_page_subtitle: "Written by Juan Diego Rodríguez",
  },
  es: {
    index_page_title: "¡Bienvenidos a mi blog de cocina en español!",
    index_page_subtitle: "Escrito por Juan Diego Rodríguez",
  },
};

Wrap the app with the provider in gatsby-ssr.js and gatsby-browser.js:

// ./gatsby-ssr.js & ./gatsby-browser.js

import * as React from "react";
import {LocationProvider} from "./src/context/LocationContext";
import {LocaleProvider} from "./src/context/LocaleContext";
import {IntlProvider} from "react-intl";
import {LanguageSelector} from "./src/components/LanguageSelector";
import {messages} from "./i18n/messages";

export const wrapPageElement = ({element, props}) => {
  const {location} = props;
  const {locale} = element.props.pageContext;

  return (
    <LocationProvider location={location}>
      <LocaleProvider locale={locale}>
        <IntlProvider messages={messages[locale]} locale={locale} defaultLocale="en">
          <LanguageSelector />
          {element}
        </IntlProvider>
      </LocaleProvider>
    </LocationProvider>
  );
};

Use FormattedMessage with an id matching a key from your messages:

// ./src/pages/index.js

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

const IndexPage = ({data}) => {
  const recipes = data.allMarkdownRemark.nodes;

  return (
    <main>
      <h1>
        <FormattedMessage id="index_page_title" />
      </h1>
      <h2>
        <FormattedMessage id="index_page_subtitle" />
      </h2>
      {recipes.map(({frontmatter}) => {
        return <RecipePreview key={frontmatter.slug} data={frontmatter} />;
      })}
    </main>
  );
};

// ...

Translation is just one part of i18n; regional number, date, and currency formatting matters too. Format recipe page dates with FormattedDate to display per current locale:

// ./src/pages/recipes/{markdownRemark.frontmatter__slug}.js

//...
import {FormattedDate} from "react-intl";

const RecipePage = ({data}) => {
  const {html, frontmatter} = data.markdownRemark;
  const {title, cover_image, date} = frontmatter;
  const cover_image_data = getImage(cover_image.image.childImageSharp.gatsbyImageData);

  return (
    <main>
      <h1>{title}</h1>
      <FormattedDate value={date} year="numeric" month="long" day="2-digit" />
      <GatsbyImage image={cover_image_data} alt={cover_image.alt} />
      <p dangerouslySetInnerHTML={{__html: html}}></p>
    </main>
  );
};

//...

Specify year, month, and day attributes to control display. The raw date 19-01-2023 renders according to the active locale:

English: January 19, 2023

Spanish: 19 de enero de 2023

To add localized text around that date, use react-intl arguments inside messages. The pattern { key, type, format } lets you inject dynamic data:

  • key — the data to format;
  • type — e.g., number, date, or time;
  • format — the display style, such as long for dates.

Declare a postedOn argument as a date in long format:

// ./i18n/messages.js

export const messages = {
  en: {
    // ...
    recipe_post_date: "Written on {postedOn, date, long}",
  },
  es: {
    // ...
    recipe_post_date: "Escrito el {postedOn, date, long}",
  },
};
// ./src/pages/recipes/{markdownRemark.frontmatter__slug}.js

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

const RecipePage = ({data}) => {
  const {html, frontmatter} = data.markdownRemark;
  const {title, cover_image, date} = frontmatter;
  const cover_image_data = getImage(cover_image.image.childImageSharp.gatsbyImageData);

  return (
    <main>
      <h1>{title}</h1>
      <FormattedMessage id="recipe_post_date" values={{postedOn: new Date(date)}} />
      <GatsbyImage image={cover_image_data} alt={cover_image.alt} />
      <p dangerouslySetInnerHTML={{__html: html}}></p>
    </main>
  );
};
//...

Note: The date must be a fresh Date object passed as the message's value.

Localizing The Document Title

The index page's title still isn't localized — recipe pages query localized titles, but the home page doesn't. The fix is tricky for two reasons:

  1. Gatsby's Head API doesn't receive the IntlProvider, so it can't use react-intl.
  2. FormattedMessage returns a component, not a plain string, so it can't live inside the title element.

Work around both:

  1. Use react-helmet inside the page component, where the provider exists.
  2. Use the react-intl imperative API — the useIntl hook returns an intl object with access to messages as strings.

The index page ends up like this:

// ./src/pages/index.js

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

const IndexPage = ({data}) => {
  const intl = useIntl();

  const recipes = data.allMarkdownRemark.nodes;

  return (
    <main>
      <Helmet>
        <title>{intl.messages.index_page_title}</title>
      </Helmet>
      <h1>
        <FormattedMessage id="index_page_title" />
      </h1>
      <h2>
        <FormattedMessage id="index_page_subtitle" />
      </h2>
      {recipes.map(({frontmatter}) => {
        return <RecipePreview key={frontmatter.slug} data={frontmatter} />;
      })}
    </main>
  );
};

// ...

Comparing With react-i18next

react-i18next is a mature i18n library with many features, hooks, and utilities similar to react-intl. Its main drawback with Gatsby is setup: you'd need a wrapper plugin in gatsby-node.js, whereas react-intl is ready immediately. Community plugins like gatsby-plugin-react-i18next and gatsby-theme-i18n-react-i18next can shorten that path.

Final Thoughts

Gatsby's plugin ecosystem is in flux, and its popularity has declined. Still, it's a capable framework worth considering for new projects with npm init gatsby. The biggest headaches arise when you need something outside the happy path — like i18n — but the plugins and patterns above should ease the pain. This concludes our practical look at i18n; a follow-up will build a custom i18n plugin to cover deeper needs.

Smashing Editorial