DEV Community

Cover image for Proper I18n with Gatsby, i18next and Sanity.io
Johannes Spohr
Johannes Spohr

Posted on

Proper I18n with Gatsby, i18next and Sanity.io

Prerequisites:
Basic knowledge of Gatsby and Sanity.io

tl;dr
You can find the starter source here and the demo here

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).

const allLanguages = ["en", "de"]
Enter fullscreen mode Exit fullscreen mode

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).

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)
      })
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's create our first pages now!

  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
  )
Enter fullscreen mode Exit fullscreen mode

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.

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

In gatsby-ssr.js and gatsby-browser.js we simply use this function:

const wrapWithI18nProvider = require("./src/components/wrapWithI18nProvider")
  .wrapWithI18nProvider

exports.wrapPageElement = wrapWithI18nProvider
Enter fullscreen mode Exit fullscreen mode

Optimize Sanity I18n

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

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

type SanityLocaleString {
    en: String
    de: String
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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

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

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:

  createRedirect({ fromPath: "/", toPath: "/en", isPermanent: true })
Enter fullscreen mode Exit fullscreen mode

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:

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

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.

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

src/locales/en/shop.json:

{
  "welcome": "Welcome to our Shop in {{city}}"
}
Enter fullscreen mode Exit fullscreen mode

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:

{
      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,
                })),
            }
          }),
      },
    },
Enter fullscreen mode Exit fullscreen mode

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!

Top comments (16)

Collapse
 
grsmto profile image
Adrien Denat • Edited

I did a similar setup but didn't know about the GraphQL resolver to filter the Sanity data. That's such a smart solution!

One thing that I realised however is that there is no real reason to separate translations in two separate data source: Sanity and json files. This complicates the setup and also editors won't be able to change whatever is in your source code json. You could just put everything in Sanity and have a single source of content and editable!

Collapse
 
jospohr profile image
Johannes Spohr

Hi Adrien, thanks! In that project I wanted to use i18next because it's used in some components I reused from a different project. It also makes the translation of snippets available with hooks / hocs and avoids prop-drilling snippets. You could of course load the i18next translations from sanity, but for me just using json files was a bit easier.

Collapse
 
jaska120 profile image
jaska120

In what way did you end up putting all the content to Sanity? Using translation keys the same way as using json files and loading them to i18n-next or some other way? Did you manage translation interpolation this way?

Collapse
 
grsmto profile image
Adrien Denat • Edited

Absolutely. I didn't need translation interpolation on that specific website but it should just work fine since Sanity API data is normal json, you can inject that as a ressource in i18n-next.
I might setup a fork of this repo soon (since I'm starting a new project really soon) so I'll try to remember and post it here as well.
But here is a snippet of how I did it:

i18next.addResourceBundle(pageContext.locale, "translation", {
  commons: mySanityData,
})

I only used this for global "commons" data but you could probably just inject all your Sanity data in i18n-next and then your component all use the i18n-next hooks to display CMS data.

Thread Thread
 
_ahmed_ab profile image
Ahmed Abdulrahman 😻

Hey Adrien could you setup up a fork or upload any example of your approach :)

Thread Thread
 
grsmto profile image
Adrien Denat
Thread Thread
 
_ahmed_ab profile image
Ahmed Abdulrahman 😻

Thanks a lot I have checked it :) I think you continue updating this project?

Collapse
 
tolchai profile image
tolchai

Any idea how should resolver for SanityLocaleBlock look like? I would be thankful for any recommendation. Thanks!

Collapse
 
grsmto profile image
Adrien Denat

Mine looks something like this:

SanityLocaleBlock: {
      localized: {
        type: `JSON`,
        resolve(source, args, context, info) {
          return source[context.locale || args.locale] || source.en;
        },
      },
    },
Enter fullscreen mode Exit fullscreen mode
Collapse
 
ieslamhesham profile image
Eslam Hesham

Can you provide us with your setup plet

Collapse
 
mralbertchen profile image
Albert Chen

Hi I've been using this starter and it's working amazingly. Very simple. One thing that I am not getting is fallback - If the language I'm translating does not have a corresponding key in the json files then it just displays the key instead of falling back to my default language (english.) I tried to debug this but could not make it work. Do you know what is causing this?

Collapse
 
mralbertchen profile image
Albert Chen

I figured it out - the fall back settings need to be set in the wrapper component instead of the SSR i18next instance during createPages. That confused me. Thank you!

Collapse
 
_ahmed_ab profile image
Ahmed Abdulrahman 😻

Is there a way to share your Gatsby + Sanity project here ?

Collapse
 
elfatherbrown profile image
Alex Borges

This is so good. Wow. Thank you sir. Im trying the same, but with directus.io instead of sanity. Lets see how it goes... :D

Collapse
 
yashvekaria profile image
Yash

Sir can you share your sanity sanityShop document object or schema from sanity ? I wanted to explore how you have created scheme from sanity side.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.