Proper I18n with Gatsby, i18next and Sanity.io
Tags: react, javascript, webdev, gatsby
Published: 2019-09-08 17:03:46

> Prerequisites: 
Basic knowledge of Gatsby and Sanity.io

>tl;dr
You can find the starter source [here](https://github.com/johannesspohr/gatsby-starter-i18next-sanity) and the demo [here](https://gatsby-starter-i18next-sanity.netlify.com/en)

Gatsby is a great tool for building blazing fast, statically generated websites. However, the solutions offered for building multilanguage websites are quite sparse, especially, when the requirements in terms of performance, SEO and user experience are high.

In my website project I needed:

- correct URLs for the languages (language in the path, translated slugs)
- multilanguage content from sanity and other sources
- a proper translation of snippets
- optimized bundle size (don't ship all translations at once)
- alternate links to other languages
- a sitemap with language information

When looking at other solutions, I didn't find gatsby-plugin-i18n to meet those requirements. They mostly rely on translated page files, which is what I wanted to avoid.
So I decided to do it manually by integrating i18next because it was already used in some other related projects.

For this example, let's assume that we want to create a website where we have a homepage and some pages for our shops in different cities.

## Pages & Snippets

Our approach will be:
- Only create pages programmatically with localized paths
- In `createPages`, use i18next to load all the translations needed to render a page
- Pass the language and the translation resources with the `pageContext`
- Wrap the templates in a `<I18nextProvider>` in `gatsby-ssr` and `gatsby-browser`. The resources are loaded from the `pageContext`.

So, let's start off by removing all files from the pages directory. Then, we want to create start pages for all languages, so we go to `gatsby-node.js`. 

First, we define all available languages (English and German for this example).

```jsx
const allLanguages = ["en", "de"]
```

Then, we need a helper function, which creates all language pages for one or more datasets. For each dataset, it will load the given namespaces into an i18next instance (1) and then use the given callback to generate the definition to create the Gatsby page (2). The Gatsby page will receive the language and the loaded language key along with the definition returned by the callback (3).
We then map over all resulting definitions, which contain the final URL and the language, to generate the alternate links (4). Finally, we create pages for all definitions (5).

```jsx
const buildI18nPages = async (
  inputData,
  pageDefinitionCallback,
  namespaces,
  createPage
) => {
  if (!Array.isArray(inputData)) inputData = [inputData]
  await Promise.all(
    inputData.map(async ipt => {
      const definitions = await Promise.all(
        allLanguages.map(async language => {
          const i18n = await createI18nextInstance(language, namespaces) // (1)
          const res = pageDefinitionCallback(ipt, language, i18n) // (2)
          res.context.language = language
          res.context.i18nResources = i18n.services.resourceStore.data // (3)
          return res
        })
      )

      const alternateLinks = definitions.map(d => ({
        // (4)
        language: d.context.language,
        path: d.path,
      }))

      definitions.forEach(d => {
        d.context.alternateLinks = alternateLinks
        createPage(d) // (5)
      })
    })
  )
}
```

Let's create our first pages now!

```jsx
  const homeTemplate = path.resolve(`src/templates/Home.js`)
  await buildI18nPages(
    null,
    (_, language) => ({
      path: "/" + language, // (1)
      component: homeTemplate,
      context: {},
    }),
    ["common", "home"],
    createPage
  )

  const shopTemplate = path.resolve(`src/templates/Shop.js`)
  const shops = await graphql(`
    query Shop {
      allSanityShop {
        edges {
          node {
            id
            _rawSlug
          }
        }
      }
    }
  `)
  await buildI18nPages(
    shops.data.allSanityShop.edges,
    ({ node }, language, i18n) => ({
      path: `/${language}/${i18n.t("common:shopSlug")}/${
        node._rawSlug[language]
      }`,
      component: shopTemplate,
      context: { shop: node.id },
    }),
    ["common", "shop"],
    createPage
  )
```

Looking at the first part creates the home page for every language on `/en` and `/de`. We load the `common` and the `home` namespace for the page. Then, we query sanity for all our shops and use our helper function to create the language versions of each shop. This time we create a localized slug (e.g. `/en/buy-stuff-in/cologne` for English and `/de/sachen-kaufen-in/koeln` for German). The way the slug is built here is completely arbitrary, you could, of course, use any logic.

To use `i18n.t` we need to create the namespace files `src/locales/(de|en)/common.json` and insert the translation for `shop-slug`, see the i18next documentation for more information.

## Wrap Content with I18nextProvider

To translate snippets in the templates, we need to wrap the page in an `<I18nextProvider>`. This can be done in `wrapPageElement`, both in `gatsby-ssr.js` and `gatsby-browser.js` environment. So we create a wrapper function that pulls the data we passed in the `pageContext` (1), instantiates i18next with our data and wraps the content in a provider. This also adds the alternate links and the lang attribute using Helmet (2). We also create a context for the links, so we can put a context-sensitive language switcher anywhere in the application.

```jsx
export const AlternateLinksContext = React.createContext([])

export function wrapWithI18nProvider({ element, props }) {
  const i18n = i18next
    .createInstance({
      lng: props.pageContext.language,
      interpolation: { escapeValue: false },
      initImmediate: false,
      resources: props.pageContext.i18nResources,
    })
    .use(ReactI18next.initReactI18next)
  // noinspection JSIgnoredPromiseFromCall
  i18n.init()
  return (
    <ReactI18next.I18nextProvider i18n={i18n}>
      <AlternateLinksContext.Provider
        value={props.pageContext && props.pageContext.alternateLinks}
      >
        {
          <Helmet htmlAttributes={{ lang: props.pageContext.language }}>
            {props.pageContext &&
              props.pageContext.alternateLinks &&
              props.pageContext.alternateLinks.map(link => (
                <link
                  rel="alternate"
                  hrefLang={link.language}
                  href={link.path}
                />
              ))}
          </Helmet>
        }
        {element}
      </AlternateLinksContext.Provider>
    </ReactI18next.I18nextProvider>
  )
}
```

In `gatsby-ssr.js` and `gatsby-browser.js` we simply use this function:
```jsx
const wrapWithI18nProvider = require("./src/components/wrapWithI18nProvider")
  .wrapWithI18nProvider

exports.wrapPageElement = wrapWithI18nProvider
```
 
## Optimize Sanity I18n

When using the suggested way for Sanity I18n you end up with a graphql schema which looks like this:

```gaphql
type SanityShop {
    _rawName: JSONObject
    _rawSlug: JSONObject
    name: SanityLocaleString
    slug: sanityLocaleString
}

type SanityLocaleString {
    en: String
    de: String
}
```

With neither `_rawName` nor `name` we only fetch a language based on a variable, which unfortunately is the only way to customize the query in Gatsby. But we can extend the schema a little bit and add a resolver for that issue in `gatsby-node.js`.

```jsx
exports.createResolvers = ({ createResolvers }) => {
  createResolvers({
    SanityLocaleString: {
      translate: {
        type: `String!`,
        args: { language: { type: "String" } },
        resolve: (source, args) => {
          return source[args.language] || source["en"]
        },
      },
    },
  })
}
```

This resolver allows us to write a query for the shop page like this, so we only get the values for the current language.

```jsx
export const query = graphql`
  query Shop($shop: String, $language: String) {
    sanityShop(id: { eq: $shop }) {
      name {
        translate(language: $language)
      }
    }
  }
`
```

By using both of the above tricks we can ensure to minimize the data we send to the client to the languages and namespaces which are needed.

## Redirecting to the correct language

When a user visits the page, we want to forward him to a language, because right now, we don't have anything to show in the root directory. Gatsby has no server, so we can't perform any server-side redirects (at least no dynamic language detection). But Gatsby offers us a method to create redirects and push the implementation of that to plugins (the netlify plugin for example). In a real-world scenario, I would redirect different TLDs to the different languages on the master TLD (`example.com` -> `example.com/en`, `example.de` -> `example.com/de/`). So in `createPages`, we can set:
```jsx
  createRedirect({ fromPath: "/", toPath: "/en", isPermanent: true })
```

We could of course also create an index page which detects the language on the client-side.

## Error Pages

When something goes wrong and the user gets to a URL that doesn't exist, we can provide a 404 page. By generating 404.html's in the language directories as we did with the homepage, we can set up redirects to use those pages in case nothing else is found:

```jsx
  allLanguages.forEach(language =>
    createRedirect({
      fromPath: `/${language}/*`,
      toPath: `/${language}/404`,
      statusCode: 404,
    })
  )
```

You can find the code for the 404 pages on GitHub, but it's mostly the same as the index page with different content.

## A Page Template

The pages we create in the templates folder do you look like regular Gatsby pages, except they have access to the language in the context.

```jsx
const ShopPage = ({ data }) => {
  const { t } = useTranslation("shop")
  return (
    <Layout>
      <SEO title="Shop" />
      <h1>{t("welcome", { city: data.sanityShop.name.translate })}</h1>
    </Layout>
  )
}
```

`src/locales/en/shop.json`:
```json
{
  "welcome": "Welcome to our Shop in {{city}}"
}
```

This page prints a translated message to welcome the user to a specific shop. It uses i18next interpolation to put the message into the string.

## Sitemap

`gatsby-plugin-sitemap` allows us to set a custom query and transformer function, so we can query the additional `pageContext` and set the sitemap data accordingly. So we add this to our plugin configuration:


```jsx
{
      resolve: `gatsby-plugin-sitemap`,
      options: {
        exclude: ["/404", "/*/404"],
        query: `
        {
          site {
            siteMetadata {
              siteUrl
            }
          }

          allSitePage {
            edges {
              node {
                path
                context {
                  alternateLinks {
                    language
                    path
                  }
                }
              }
            }
          }
      }`,
        serialize: ({ site, allSitePage }) =>
          allSitePage.edges.map(edge => {
            return {
              url: site.siteMetadata.siteUrl + edge.node.path,
              changefreq: `daily`,
              priority: 0.7,
              links:
                edge.node.context.alternateLinks &&
                edge.node.context.alternateLinks.map(link => ({
                  lang: link.language,
                  url: site.siteMetadata.siteUrl + link.path,
                })),
            }
          }),
      },
    },
```

## Demo
You can find a working example of all this here: 
https://github.com/johannesspohr/gatsby-starter-sanity-i18next
The preview can be seen here: 
https://gatsby-starter-i18next-sanity.netlify.com/


Feel free to comment on your experiences on i18n with Gatsby!