DEV Community

Cover image for Multilingual website with Gatsby and Contentful - Part 2
Louis Bertin
Louis Bertin

Posted on • Updated on

Multilingual website with Gatsby and Contentful - Part 2

⚠️ Sometimes, it happens that graphQL don't find your intl data located in intl , run the command gatsby clean to remove the cache 🙂

Prerequisite

React-intl setup

First, you have to install the plugin based on react-intl which offers you to translate your website.

⚠️ I'm French so, I define the other locale to fr put your own instead!

  • Install your plugin : npm install --save gatsby-plugin-intl
  • Now, configure your config file : gatsby-config.js
{
    resolve: `gatsby-plugin-intl`,
    options: {
        // language JSON resource path
        path: `${__dirname}/src/intl`,
        // supported language
        languages: [`en`, `fr`],
        // language file path
        defaultLanguage: `en`,
        // option to redirect to `/en` when connecting `/`
        redirect: true,
    },
},
Enter fullscreen mode Exit fullscreen mode
  • You can create the intl folder into the src and create two files corresponding to your locales
// src/intl/en.json
{
    "title": "Gatsby English",
    "description": "Project description",
    "author": "@louisbertin",
    "hello": "Hi people!",
    "welcome": "Welcome to your new Gatsby site.",
    "title_page2": "Page two",
    "hello_page2": "Hi from the second page",
    "go_page2": "Go to page 2",
    "welcome_page2": "Welcome to page 2",
    "go_back": "Go back to the homepage",
    "footer_build": "Built with",
    "notfound": {
        "header": "NOT FOUND",
        "description": "You just hit a route that doesn't exist... the sadness."
    }
}
Enter fullscreen mode Exit fullscreen mode
// src/intl/fr.json
{
    "title": "Gatsby French",
    "description": "Project description",
    "author": "@louisbertin",
    "hello": "Salut les gens!",
    "welcome": "Bienvenue sur votre nouveau site Gatsby",
    "title_page2": "Page deux",
    "hello_page2": "Salut depuis la seconde page",
    "go_page2": "Aller à la page 2",
    "welcome_page2": "Bienvenue sur la page 2",
    "go_back": "Retour à la page d'accueil",
    "footer_build": "Construit avec",
    "notfound": {
        "header": "NOT FOUND",
        "description": "Vous voulez accéder à une route qui n'existe pas..."
    }
}
Enter fullscreen mode Exit fullscreen mode

Build multilingual blog

  • To use all keys located in intl folder you have to change all your react components and pages to work with react-intl. I'll give you an example with the index page.
// pages/index.js
import React from "react"
import { graphql } from "gatsby"

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

import { injectIntl, Link, FormattedMessage } from "gatsby-plugin-intl"

const IndexPage = ({ data, intl }) => (
    <Layout>
        <SEO title={intl.formatMessage({ id: "title" })} />
        <h1>
          <FormattedMessage id="hello" />
        </h1>
        <p>
          <FormattedMessage id="welcome" />
        </p>

        <br />
        <Link to="/page-2">
          <FormattedMessage id="go_page2" />
        </Link>
      </Layout>
)

export const query = graphql `
      query ContentFulPosts($locale: String) {
        allContentfulPost(filter: { node_locale: { eq: $locale } }) {
          nodes {
            contentful_id
            title
            path
          }
        }
      }
`

export default injectIntl(IndexPage)
Enter fullscreen mode Exit fullscreen mode

and with a component page 🙂

import React from "react"
import PropTypes from "prop-types"
import { useStaticQuery, graphql } from "gatsby"

import Header from "./header"
import "./layout.css"

import { injectIntl, FormattedMessage } from "gatsby-plugin-intl"

const Layout = ({ children, intl }) => {
  const data = useStaticQuery(graphql`
    query SiteTitleQuery {
      site {
        siteMetadata {
          title
        }
      }
    }
  `)

  return (
    <>
      <Header siteTitle={data.site.siteMetadata.title} />
      <div
        style={{
          margin: `0 auto`,
          maxWidth: 960,
          padding: `0px 1.0875rem 1.45rem`,
          paddingTop: 0,
        }}
      >
        <main>{children}</main>
        <footer>
          © {new Date().getFullYear()}, <FormattedMessage id="footer_build" />{" "}
          {` `}
          <a href="https://www.gatsbyjs.org">Gatsby</a>
        </footer>
      </div>
    </>
  )
}

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

export default injectIntl(Layout)
Enter fullscreen mode Exit fullscreen mode
  • Before retrieving the data, you have to create the blog post template. This template has to fetch the current locale to get the correct data.
import React from "react"
import { graphql } from "gatsby"

import Layout from "../components/layout"

const BlogPost = ({ pageContext, data }) => (
    <Layout>
        <h1>{data.contentfulPost.title}</h1>
      </Layout>
)

export const query = graphql `
      query ContentFulPost($slug: String, $locale: String) {
        contentfulPost(path: { eq: $slug }, node_locale: { eq: $locale }) {
          path
          node_locale
          title
        }
      }
    `

export default BlogPost
Enter fullscreen mode Exit fullscreen mode
  • Now, it's time to use gatsby-node file to generate posts pages for each locale!
const path = require(`path`)

// pages locale
exports.onCreatePage = ({ page, actions }) => {
    const { createPage, deletePage } = actions
    deletePage(page)
    // You can access the variable "locale" in your page queries now
    createPage({
        ...page,
        context: {
            ...page.context,
            locale: page.context.intl.language,
        },
    })
}

// blog posts
exports.createPages = ({ graphql, actions }) => {
    const { createPage } = actions
    const blogPostTemplate = path.resolve(`src/templates/blog-post.js`)
    // Query for markdown nodes to use in creating pages.
    // You can query for whatever data you want to create pages for e.g.
    // products, portfolio items, landing pages, etc.
    // Variables can be added as the second function parameter
    return graphql(
        `
          query MyQuery {
            allContentfulPost {
              edges {
                node {
                  path
                }
              }
            }
          }
        `
    ).then(result => {
        if(result.errors) {
            throw result.errors
        }

        // Create blog post pages.
        result.data.allContentfulPost.edges.forEach(edge => {
            const path = edge.node.path

            createPage({
                // Path for this page — required
                path: path,
                component: blogPostTemplate,
                context: {
                    slug: path,
                },
            })
        })
    })
}
Enter fullscreen mode Exit fullscreen mode
  • Then, update graphQL query on index to display the right post
export const query = graphql `
      query ContentFulPosts($locale: String) {
        allContentfulPost(filter: { node_locale: { eq: $locale } }) {
          nodes {
            contentful_id
            title
            path
          }
        }
      }
Enter fullscreen mode Exit fullscreen mode
  • Finally, you can access your posts and multilingual seems to work.. but to test everything we need to have a language switcher! So, create a component called Language.js and place it in the components folder of your project
import React from "react"
import { IntlContextConsumer, changeLocale } from "gatsby-plugin-intl"

const languageName = {
  en: "English",
  fr: "Français",
}

const Language = () => (
  <div>
    <IntlContextConsumer>
      {({ languages, language: currentLocale }) =>
        languages.map(language => (
          <a
            key={language}
            onClick={() => changeLocale(language)}
            style={{
              color: currentLocale === language ? `yellow` : `white`,
              margin: 10,
              textDecoration: `underline`,
              cursor: `pointer`,
            }}
          >
            {languageName[language]}
          </a>
        ))
      }
    </IntlContextConsumer>
  </div>
)

export default Language
Enter fullscreen mode Exit fullscreen mode

You can call the language component in the Header component for example and everything should work!

In the next post

In the next post I will explain how to deploy your multilingual blog on Netlify!

By the way, you can find my code on Github!

Top comments (15)

Collapse
 
naxes profile image
Seán Bickmore

I originally left a comment here regarding translated slugs, but removed it in lieu of finding somewhat of a working solution. I thought I'd follow up on this in case anyone is stuck or possibly has a better one.

The Problem

In this context, the slug is assumed to be the same for each locale. For example:

/page/
/fr/page/
/de/page/
... and so on
Enter fullscreen mode Exit fullscreen mode

But, what if you want the slugs themselves to be translations of each other?

/english-page/
/fr/french-page/
/de/german-page/
Enter fullscreen mode Exit fullscreen mode

You can set this field up for localization in Contentful, but the issue I found with this is that it will generate each page in each language under every locale:

english-page/
fr/
  english-page/
  french-page/
  german-page/
de/
  english-page/
  french-page/
  german-page/
en/
  english-page/
  french-page/
  german-page/
Enter fullscreen mode Exit fullscreen mode

Meaning, if I were to visit /fr/english-page/ I'd be seeing English content, which is not ideal. Naturally, if they share the same slug this wont happen, but what we actually want here is:

english-page/
fr/
  french-page/
de/
  german-page/
en/
  english-page/
Enter fullscreen mode Exit fullscreen mode

The Solution

The basic gist of the solution to this for me was to be able to determine that the page being created was of a specific language. First thing was to pass in the node_locale of the page to the context:

// Don't forget to add 'node_locale' to your query first
result.data.allContentfulPost.edges.forEach(edge => {
  const { path, node_locale } = edge.node;

  createPage({
    path: path,
    component: blogPostTemplate,
    context: {
      slug: path,
      node_locale,
    },
  });
});

Enter fullscreen mode Exit fullscreen mode

Now in onCreatePage we know the locale. In addition to this, page.context.intl can provide us the language, meaning if we compare these two as follows we will be able to determine if the locale and language of the page match:

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


  /**
   * Now it will only create pages under
   * each locale of the correct language
   * as opposed to ALL languages for each.
   */
  if (node_locale === language) {
    createPage({
      ...page,
      context: {
        ...page.context,
        locale: page.context.intl.language,
      },
    });
  }
};
Enter fullscreen mode Exit fullscreen mode

This will filter out the pages for each locale, but there's still an issue with this. It also filters out /, /dev-404-page/ etc. Quick and dirty solution for this problem:

exports.onCreatePage = ({ page, actions }) => {
  const { createPage, deletePage } = actions;
  const { node_locale } = page.context;
  const { language, originalPath } = page.context.intl;
  deletePage(page)

  const pathExceptions = ['/', '/dev-404-page/'];
  const localeCheck = node_locale === language;
  const pathCheck = pathExceptions.indexOf(originalPath) !== -1;

  if (localeCheck || pathCheck) {
    createPage({
      ...page,
      context: {
        ...page.context,
        locale: page.context.intl.language,
      },
    });
  }
};

Enter fullscreen mode Exit fullscreen mode

Conclusion

This is what worked for me given a time constraint as it was a requirement to have translated slugs for the project, though the implementation is imperfect since in the case the slug is the same, it wont create a page for that locale. Good example of this would be if you had multiple English locales, meaning the slug actually wouldn't be different. Wondering if there's a better way to handle this?

Collapse
 
petriczechcom profile image
Petriczech

Thank you for this man. I was struggling a lot with this. I upgraded it a bit since I'm using the category pages too.

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


  if(page.context.type === 'blog' || page.context.type === 'category') {
    deletePage(page)

    /**
     * Now it will only create pages under
     * each locale of the correct language
     * as opposed to ALL languages for each.
     */
    if (locale === language) {
      createPage({
        ...page,
        context: {
          ...page.context,
          locale: page.context.intl.language,
        },
      });
    }
  }
};

I had to add locale and type to post and category context

createPage({
        path: post.node.fields.slug,
        component: blogPostTemplate,
        context: {
          slug: post.node.fields.slug,
          previous,
          next,
          locale: post.node.frontmatter.locale,
          type: "blog",
        },
      })

and category respectively

 createPage({
        path: `/blog/${category.fieldValue}/`,
        component: categoriesTemplate,
        context: {
          category: category.fieldValue,
          locale: category.edges[0].node.frontmatter.locale,
          type: "category",
        },
      })
Collapse
 
strehlst profile image
Steven • Edited

Hi Seán, thanks a lot for your workaround!

Did you come across a solution for the case slug are actualle equal on purpose or because even translated they give the same (e.g. France is France in English and in French, Portugal as well).

That would be awesome!

Collapse
 
dinhhuyams profile image
TrinhDinhHuy

If I fetch the locale data from contentful, do I need to create the src/intl/fr.json and src/intl/en.json?

Collapse
 
louisbertin profile image
Louis Bertin

It depends on what you want to achieve.
You can deal with static files for the content that will never change.

Collapse
 
zasuh_ profile image
Žane Suhadolnik

I'm using gatsby-plugin-intl aswell. How do you go about mapping through an array that is translated?

Example:

{
  "contact" : "Kontakt",
  "biography" : "Biografija",
  "about": "Jože je luč sveta ugledal sredi še črno-belih šestdesetih let v Ljubljani. Že pri svojih zgodnjih šestnajstih letih je fotografiral za takratno jugoslovansko tiskovno agencijo Tanjug in revijo Mladina. Kot fotoreporter je v začetku devetdesetih delal za časopis Dnevnik in revijo Mladina. Leta 1993 pa je soustanovil fotoagencijo Bobo, hkrati pa sodeloval z agencijami Reuters, Associated Press in EPA. Na prehodu v 21. stoletje je nekaj let delal kot fotograf za Delo revije. Danes je zaposlen kot fotoreporter za časopis Delo.",
  "biography_link": "Biografija",
  "exhibitions_link": "Razstave",
  "exhibitions_title": "Razstave",
  "books_link": "Knjige",
  "exhibitionList": [
    "1984 »Portreti«, Galerija ŠKUC, Ljubljana",
    "1986 »Portreti«, Galerija ŠKUC, Ljubljana",
    "1986 »Portreti«, Bologna, Italija",
    "1986 »Portreti«, Galerija Fenix, Ljubljana",
    "1987 »Portreti«, Bologna, Italija",
    "1989 »Portreti«, Galerija FNAC, Pariz, Francija",
    "1989 »Portreti«, Gledališče, Celje",
    "1995 »Eritreja«, Galerija ŠKUC , Ljubljana",
    "1996 »Pregledna razstava portretov«, Cankarjev Dom, Ljubljana",
    "1998 »Portreti«, Gledališče Franceta Prešerna, Kranj",
    "2001 »Portreti«, A-Banka, Ljubljana",
    "2001 »Portreti«, Kamerni Teatar, Sarajevo, Bosna in Hercegovina",
    "2004 »Jutra v Rusiji«, Založba Beletrina",
    "2004 »Jutra v Rusiji«, Festival Medana",
    "2004 »NSK 1980-2000«, Galerija Fotografija, Ljubljana",
    "2004 »Jutra v Rusiji«, Galerija Moskva Petuški, Tolmin",
    "2004 »Občutek za veter«, Lendava",
    "2004 »Jutra v Rusiji«, Fotopub, Novo Mesto",
    "2005 »Pregledna razstava«, Galerija Herman Pečarič, Piran",
    "2005 »Občutek za veter«, Velika Polana",
    "2005 »Drežniški pustolovi«, Fotopub, Novo Mesto",
    "2005 »Pregledna razstava«, Galerija ŠKUC, LJjubljana",
    "2006 »Ostanki dneva« Galerija Photon, Ljubljana",
    "2006 »Sirarji«, Žalec",
    "2007 »Sirarji«, Slovenska Ambasada Bruselj, Belgija",
    "2009 »Auslenderji«, Gaelrija modernih umetnosti, Slovenj Gradec",
    "2009 »Auslenderji«, Delavski Dom, Trbovlje",
    "2010 »Portreti slovenskih pesnikov in pisateljev«, Mestna Hiša, Ljubljana",
    "2010 »NSK 1980-2000«, Galerija Korotan, Dunaj, Avstrija",
    "2012 »Album, NSK«, Galerija Jakopič, Ljubljana"
  ],
  "copied": "Kopirano!",
  "clickToCopy": "Kliknite za kopiranje email naslova",
  "contact_title": "Kontakt",
  "contact_name": "Ime in priimek",
  "contact_email": "Email",
  "contact_subject": "Zadeva",
  "contact_message": "Sporočilo",
  "contact_upload_file": "Naloži datoteko",
  "contact_send_msg": "Pošlji sporočilo"
}

Component where I want to map:

import React from 'react'
import styled from 'styled-components'
import { useIntl } from 'gatsby-plugin-intl'
import uuid from 'uuid'
import { Layout, AboutHeader, SideBar } from '../components'
import config from '../../config/about'

const BG = styled.div`
  background-color: ${props => props.theme.colors.bg};
`

const Exhibitions = styled.ul`
  max-width: 600px;
  margin: 0 auto;
  padding: 30px;
`

const About = () => {
  const intl = useIntl()
  const exhibitionList = intl.formatMessage({ id: 'exhibitionList' }) // Looping over this doesn't work
  console.log(exhibitionList)
  return (
    <Layout customSEO id="outer-container">
      <SideBar right pageWrapId="page-wrap" outerContainerId="outer-container" />
      <AboutHeader />
      <BG id="page-wrap">
        <Exhibitions>
          {config.exhibitionList.map(item => {
            return <li key={uuid.v4()}>{item}</li>
          })}
        </Exhibitions>
      </BG>
    </Layout>
  )
}

export default About

Collapse
 
louisbertin profile image
Louis Bertin

Hi!

I have created the same kind of data in my intl file :

{
    "title": "Gatsby English",
    "description": "Project description",
    "author": "@louisbertin",
    "hello": "Hi people!",
    "welcome": "Welcome to your new Gatsby site.",
    "title_page2": "Page two",
    "hello_page2": "Hi from the second page",
    "go_page2": "Go to page 2",
    "welcome_page2": "Welcome to page 2",
    "go_back": "Go back to the homepage",
    "footer_build": "Built with",
    "test": [
        "hello",
        "world"
    ],
    "notfound": {
        "header": "NOT FOUND",
        "description": "You just hit a route that doesn't exist... the sadness."
    }
}

If I try to console.log(intl.messages) in one of my components I can see that react-intl not return an array of string but it creates multiple keys based on the number of items in the array :

Postgres issue on startup

So, you can create an array based on the object keys :

  var test = Object.keys(intl.messages).reduce((acc, item) => {
    if (item.includes("test")) {
      return [...acc, intl.messages[item]]
    }
    return acc
  }, [])
  console.log(test)

It returns :

["hello", "world"]

Now you can loop through this array and display all your data 🙂

Or.. if you know the number of items in your list (not recommended but faster) :

{[0, 1].map(index => (
   <FormattedMessage id={`test.${index}`} />
))}
Collapse
 
zasuh_ profile image
Žane Suhadolnik

Thank you for the answer! I ended up doing something similar: spectrum.chat/gatsby-js/general/us...

Collapse
 
zooly profile image
Hugo Torzuoli

Thanks for this post, very nice and not a lot explained on the web!

Concerning the warning in the intro, do you know what gatsby clean command exactly do? I've already meet this issue with Contentful multilanguage.

Collapse
 
louisbertin profile image
Louis Bertin

Thanks a lot!

gatsby clean is a command line dedicated to remove ./cache folder and wipe out your ./public folder.

You can find more informations about this command on the official Gatsby documentation 🙂

gatsbyjs.org/docs/gatsby-cli/#clean
gatsbyjs.org/docs/debugging-cache-...

Collapse
 
lotfizouad profile image
lotfi zouad

Hi, thanks for the post! I did something similar and tried with the same language component but I noticed that if add for instance /fr in the address on the browser and then click english, it doesn't work and I see (locally) GET localhost:9000/page-data/en/fr/pag... 404, like gatsby tries to fetch data for en/fr because it preprends en but doesn't remove fr. But if I go to localhost:9000/ then it works fine.
This behavior happens in a prod conf with gatsby build and serve, but works fine with gatsby develop. Have you encountered that too?

Collapse
 
vlknhslk profile image
Volkan Haslak

Thank you for this post - it is very helpful. I am working on my project and e.g. I have en and ar and en is the default language. If I call changeLocale('en') on /en/about I'd like to go to /about not /en/about. Is that possible?

Collapse
 
razdvapoka profile image
razdvapoka

Thank you! Your tutorial helped me A LOT! I think that this actually is the easiest way to do i18n in a Gatsby project I've found so far. I've successfully used it in two of my projects so far.

Collapse
 
louisbertin profile image
Louis Bertin

Thank you! Good to hear!

I gonna make post about the same subject in the following weeks with an improved method. Stay tuned! 😉

Collapse
 
abhishek305 profile image
Abhishek Ezhava

One question why do we need en.json or fr.json files in intl folder ?
As our data is coming from contentful
@louisbertin