SEO + DX focused internationalization for Gatsby

Table of contents

In this post, we’ll see how we can support multiple languages in our Gatsby application with react-intl. The main objectives are the following:

  • Good SEO by rendering in the initial HTML we send to the user our meta tags and of course our content.
  • Good developer experience (DX) by maintaining only one page, and automatically creating copies for that page for each language we want to support.

Disclaimers

Last time I checked, there wasn’t a definitive guide on how to implement internationalization (i18n for short) in the Gatsby documentation. There are of course many approaches and different libraries you can use for i18n if you search on GitHub. The only solution I found that satisfies our 2 objectives is the one in this GitHub issue.

I want to be honest, the purpose of this post is quite selfish. I intend to use it as a “cookbook” for my custom gatsby-starter. As a result, it’ll be opinionated, and I won’t go into a lot of details explaining the code and the tools I use. In addition to that, I don’t know react-intl in-depth, so I don’t want to suggest or claim something that is not true. So if you want something that explains a bit more, check the original post about react-inl in Gatsby. If you are still not satisfied by the original post, and you have some experience in Gatsby, you can return back here and see what I do. With that out of the way, let’s start by setting up the project.

Project setup

As I said, I’ll be using my custom Gatsby starter. This starter focuses on the design and on simple sites with static content (it’s mainly for small businesses). Open a terminal, and type the following commands:

gatsby new gatsby-react-intl https://github.com/MarkosKon/gatsby-starter.git
cd gatsby-react-intl
yarn add react-intl

Now that we’ve installed our dependencies, let’s make a plan. (By the way, we’re going to support 2 languages, Greek and English).

The plan

  1. One of the most important things we’ll need to do is to override the onCreatePage method in gatsby-node.js. For each page we have in the src/pages directory, we’ll create a new page for each language we want to support. We’ll also pass as page context to that page some language info that we’ll need later in our components.
  2. We’ll also need an SEO component that we’ve already have in our src/components/SEO.jsx.
  3. A LanguageSwitcher component. We only have two languages, so we’ll implement it as a component with two links and “globe” icon. If we had more, it would make more sense to create a dropdown.
  4. 2 files with our translated messages, in JSON format, one for Greek and one for English.
  5. A LocalizedLink component that will output the correct page depending on the language the user selects. Remember that the page we maintain is one, but the languages are two.
  6. Finally, we’ll use react-intl to glue all those parts together.

Let’s start with step 4. We’ll create a locales folder inside our src folder that will contain some information about our languages. Let’s create an index.js file with the following content (it has to be in CommonJS):

src/locales/index.js
module.exports = {
  en: {
    path: "en",
    locale: "English",
    localeShort: "en",
    default: true,
  },
  el: {
    path: "el",
    locale: "Ελληνικά",
    localeShort: "ελ",
  },
};

Now, let’s create 2 JSON files with our messages:

src/locales/el.json
{
  "navbar": "Γειά σου",
  "heading": "Γειά σου",
  "tagline": "Κόσμε!"
}
src/locales/en.json
{
  "navbar": "Hello",
  "heading": "Hello",
  "tagline": "World!"
}

Let’s now return to step 1 and follow the original order. We’re going to override the onCreatePage method in gatsby-node.js:

gatsby-node.js
const locales = require("./src/locales");

exports.onCreatePage = ({ page, actions }) => {
  const { createPage, deletePage } = actions;

  return new Promise((resolve) => {
    deletePage(page);

    Object.keys(locales).map((lang) => {
      const localizedPath = locales[lang].default
        ? page.path
        : locales[lang].path + page.path;

      return createPage({
        ...page,
        path: localizedPath,
        context: {
          locale: lang,
        },
      });
    });

    resolve();
  });
};

Here we override the default onCreatePage method that creates pages from the files we have in the src/pages folder. We import the language data we created earlier, and for each language, we create a new page. We pass into those pages the path (localizedPath), the actual page, and the locale string as context which we’ll use inside our components. Don’t forget to restart the development server after creating this file if you want the changes to take effect.

Create the language-specific components

Now before we set up and use react-intl, let’s create the components we’ll need. We start with the LanguageSwitcher component:

src/components/LanguageSwitcher.jsx
import React from "react";
import PropTypes from "prop-types";
import styled from "styled-components";
import { injectIntl } from "react-intl";
// eslint-disable-next-line import/no-extraneous-dependencies
import { Location } from "@reach/router";

import { Globe } from "./Icons";
import Link from "./Link";
import locales from "../locales";

const Nav = styled.nav`
  display: flex;
  align-items: center;
  background-color: transparent;
  box-shadow: none;

  svg {
    font-size: 22px;
    margin-left: 60px;
  }
`;
const toPage = (pathname) => pathname.split(/\/[a-z]{2}\//).pop();

const LanguageSwitcher = ({ intl: { locale } }) => (
  <Location>
    {({ location: { pathname } }) => (
      <Nav>
        <span>
          <Globe />
        </span>
        {Object.keys(locales).map((key) => (
          <Link
            key={locales[key].locale}
            ml={3}
            color={key === locale ? "red" : "white"}
            to={
              locales[key].default
                ? `/${toPage(pathname)}`
                : `/${locales[key].path}/${toPage(pathname)}`
            }
          >
            {locales[key].localeShort}
          </Link>
        ))}
      </Nav>
    )}
  </Location>
);

LanguageSwitcher.propTypes = {
  intl: PropTypes.shape({
    locale: PropTypes.string,
  }).isRequired,
};

export default injectIntl(LanguageSwitcher);

If the Globe component doesn’t exist, you can add it in the src/components/Icons.jsx file:

src/components/Icons.jsx
import {
  faHeart,
  faLink,
  faGlobeAfrica,
} from "@fortawesome/free-solid-svg-icons";

// ... skipping stuff

const Globe = (props) => (
  <FontAwesomeIcon {...props} icon={faGlobeAfrica} />
);

export {
  Facebook,
  GooglePlus,
  Linkedin,
  Skype,
  Twitter,
  Instagram,
  Envelope,
  Heart,
  FaLink,
  Globe,
};

We continue with the LocalizedLink component:

src/components/LocalizedLink.jsx
import React from "react";
import PropTypes from "prop-types";
import { injectIntl } from "react-intl";

import Link from "./Link";

import locales from "../locales";

const LocalizedLink = ({ to, intl: { locale }, ...props }) => {
  const path = locales[locale].default ? to : `/${locale}${to}`;

  return <Link {...props} to={path} />;
};

LocalizedLink.propTypes = {
  to: PropTypes.string.isRequired,
  intl: PropTypes.shape({
    locale: PropTypes.string,
  }).isRequired,
};

export default injectIntl(LocalizedLink);

Notice the injectIntl HOC from react-intl that we use to get the locale as a prop in our component. react-intl uses the Context API to pass down the language data.

And we’re finished with our components; let’s now set up react-intl.

Setting up react-intl

We’ll need to initialize react-intl and create a context provider. The place that makes the most sense to do this is our reusable layout file. We go to our src/layouts/Layout.jsx file, and we paste the following code—pay attention only to the highlighted lines:

src/layouts/Layout.jsx
import React from "react";
import PropTypes from "prop-types";
import styled, { ThemeProvider } from "styled-components";
import { StaticQuery, graphql } from "gatsby";
import { Navbar, DesktopListEmpty } from "already-styled-components";
import {
  FormattedMessage,
  IntlProvider,
  addLocaleData,
} from "react-intl";

// Locale data
import enData from "react-intl/locale-data/en";
import elData from "react-intl/locale-data/el";

// Messages
import en from "../locales/en";
import el from "../locales/el";

import GlobalStyle from "./GlobalStyle";
import theme from "./theme";
import { Box } from "../components/Primitives";
import Link from "../components/Link";
import LanguageSwitcher from "../components/LanguageSwitcher";

// initialize some things
const messages = { en, el };

addLocaleData([...enData, ...elData]);

const DesktopList = styled(DesktopListEmpty)`
  display: flex;
  align-items: center;
  nav {
    margin-left: auto;
    margin-right: 32px;
  }
`;

const Layout = ({ locale, children }) => (
  <StaticQuery
    query={graphql`
      query SiteTitleQuery {
        site {
          siteMetadata {
            title
          }
        }
      }
    `}
    render={(data) => (
      <ThemeProvider theme={theme}>
        <IntlProvider locale={locale} messages={messages[locale]}>
          <>
            <GlobalStyle />
            <Navbar
              bc="rebeccapurple"
              desktopList={(props) => (
                <DesktopList {...props}>
                  <Link
                    to="/"
                    px={4}
                    style={{ color: "white", textDecoration: "none" }}
                  >
                    <h1>{data.site.siteMetadata.title}</h1>
                    <sup>
                      <FormattedMessage id="navbar" />
                    </sup>
                  </Link>
                  <LanguageSwitcher />
                </DesktopList>
              )}
            >
              <Link to="/">Home</Link>
              <Link to="/page-2">Page 2</Link>
            </Navbar>
            <Box width={[1, "80%"]} m="auto" px={[3, 5]}>
              {children}
            </Box>
          </>
        </IntlProvider>
      </ThemeProvider>
    )}
  />
);

Layout.propTypes = {
  locale: PropTypes.string.isRequired,
  children: PropTypes.node.isRequired,
};

export default Layout;

As you can see in the code above, we import the locale data from react-intl, and we “add” them with the addLocaleData method. We also import the messages we created earlier and pass them to an IntlProvider as a prop. In addition to the messages, we also pass the locale to the IntlProvider. You may be wondering where that locale prop comes from. We mentioned something earlier while creating our gatsby-node.js file. We’ll see exactly where it’s coming from in the next section where we going to use react-intl to translate our pages.

Using react-intl in our pages

We can obviously delete any page we don’t need, and we can keep only the 404 and index pages. We go to our index page and paste the following code. Again pay attention to the highlighted code:

src/pages/index.jsx
import React from "react";
import PropTypes from "prop-types";
import { FormattedMessage } from "react-intl";

import { Box, Heading, Text } from "../components/Primitives";
import Layout from "../layouts/Layout";
import SEO from "../components/SEO";

const IndexPage = ({ pageContext: { locale } }) => (
  <Layout locale={locale}>
    <SEO title="Home" keywords={["gatsby", "application", "react"]} />
    <Box height="calc(100vh - 100px)" pt={5}>
      <Heading as="h1" variant="h1">
        <FormattedMessage id="heading" />
      </Heading>
      <Text as="p" variant="wide">
        <FormattedMessage id="tagline" />
      </Text>
    </Box>
  </Layout>
);

IndexPage.propTypes = {
  pageContext: PropTypes.shape({
    locale: PropTypes.string,
  }).isRequired,
};

export default IndexPage;

As we can see, the locale prop we pass to our Layout component comes from our pages. It’s the pageContext we created in our gatsby-node.js file while duplicating our pages for each language. We also use the FormattedMessage component to output the messages we created earlier. Feel free to create more pages and messages to test the application.

Summary

We achieved our first objective which was to have good SEO. To prove this, you’ll have to build your site and serve it locally with:

gatsby build && gatsby serve

If you go to your browser and inspect the initial HTML, you’ll see that we have both our meta tags and our content available before any JavaScript runs. That’s something the search engines love. If you have the gatsby-plugin-offline installed, you’ll have to curl the server to see the actual page.

We also completed our second objective which was to have a good developer experience. We maintain only one copy of the page, and we output our i18n content with the FormattedMessage component.

Other things to read

Popular

Previous/Next