Route360

How to Create a Multilingual Blog with Gatsby.js + Markdown

Table of Contents

This website is a 3 languages blog made with Gatsby.js. It's realized without any i18n plugins or modules, but only with the routing by gatsby-node.js. *It was running with Next.js until April 2023.

Gatsby.js does not have a built-in feature to get the default language or the current display language, like Next.js's useRouter(). There is also no distinction between "default language" and "other languages" in the settings.

This article will show you how I internationalized (i18n) this site, the steps involved and the key points. There are probably other ways for the internationalization, but please read this as one example.

The code for this blog is published on GitHub repository.

Key Points for Creating a Multilingual Site with Gatsby.js

  1. generate pages by language with gatsby-node.js
  2. pass the current language to pageContext in the first step
  3. In each template, get the current language from pageContext and pass it to components such as Header and Footer to display differently for each language.

In this way, you can create a multilingual site with Gatsby.js without the need for i18n plug-ins.

Structure of Markdown files

Markdown files are arranged as follows;

src/
├─ content/
| └─ posts/
|    ├─ first-post/
|    |    ├─ en.md
|    |    ├─ fr.md
|    |    └─ ja.md
|    ├─ second-post/
|    |    ├─ en.md
|    |    ├─ fr.md
|    |    └─ ja.md

I use the folder name as a slug and name the files [lang].md for each language. By doing this, I avoid the hassle of adding the slug and language code to the frontmatter metadata and each filename.

Therefore, in gatsby-node.js, I add the slug and language schema with the following code so that the GraphQL query in each post (MarkdownRemark) can retrieve the filename (as slug) and language.

gatsby-node.js
exports.onCreateNode = ({ node, actions, getNode }) => {
  const { createNodeField } = actions

  if (node.internal.type === "MarkdownRemark") {
    const fileNode = getNode(node.parent)

    createNodeField({
      node,
      name: "language",
      value: fileNode.name,
    })

    createNodeField({
      node,
      name: "slug",
      value: fileNode.relativeDirectory.match(/\/(.+)/)[1],
    })
  }
}

Link - onCreateNode | Gatsby.js

The above code will allow you to get the slug and language name of each post from a GraphQL query. Of course, it can also be used for filtering and sorting.

query {
  markdownRemark {
    fields {
      language
      slug
    }
    frontmatter {
      ...
    }
  }
}

How to create paths and generate pages by language

Next, we will use gatsby-node.js to create the paths for the pages we want to create.

It is cumbersome, but the query should be generated by language.

  • Template name for individual article: post.js
  • Template name for top page (all articles): index.js

By generating queries by language, we can get the following benefits:

  • Easier paging when the number of posts in each language is different
  • Previous and next posts can be retrieved by language

Generating individual article pages

gatsby-node.js
exports.createPages = async ({ actions, graphql, reporter }) => {
  const { createPage } = actions

  const blogresult = await graphql(`
    query {
      allPostsEN: allMarkdownRemark(
        filter: { fields: { language: { eq: "en" } } }
        sort: { frontmatter: { date: DESC } }
      ) {
        edges {
          node {
            id
            fields {
              slug
            }
          }
        }
      }
      allPostsFR: allMarkdownRemark(
        filter: { fields: { language: { eq: "fr" } } }
        sort: { frontmatter: { date: DESC } }
      ) {
        edges {
          node {
            id
            fields {
              slug
            }
          }
        }
      }
      allPostsJA: allMarkdownRemark(
        filter: { fields: { language: { eq: "ja" } } }
        sort: { frontmatter: { date: DESC } }
      ) {
        edges {
          node {
            id
            fields {
              slug
            }
          }
        }
      }
    }
  `)

  if (blogresult.errors) {
    reporter.panicOnBuild(`Query error!`)
    return
  }

  // Single Post Pages
  const allPosts = {
    en: blogresult.data.allPostsEN,
    fr: blogresult.data.allPostsFR,
    ja: blogresult.data.allPostsJA,
  }

  Object.keys(allPosts).forEach(key => {
    allPosts[key].edges.forEach(({ node }) => {
      createPage({
        path: `/${key}/post/${node.fields.slug}/`,
        component: require.resolve(`./src/templates/post.js`),
        context: {
          id: node.id,
          slug: node.fields.slug,
          language: key,
        },
      })
    })
  })
}

I will not explain paging here, but the possibility that the number of total posts may vary by language should also be taken into account.

If a listing page contains a fifth page in Japanese but only four pages in English, taking queries by language will avoid creating a wasteful page.

Generating the article list page

Next, add the code to create an articles list page, as shown on the top page of this blog.

gatsby-node.js
exports.createPages = async ({ actions, graphql, reporter }) => {
  //...

  // Index Pages
  Object.keys(allPosts).forEach(key => {
    createPage({
      path: `/${key}/`,
      component: require.resolve(`./src/templates/index.js`),
      context: {
        language: key,
      },
    })
  })
}

For reference, this blog, which includes a paging feature, does the following (the repository);

gatsby-node.js
exports.createPages = async ({ actions, graphql, reporter }) => {
  // add totalCount query to const blogresult

  const postsPerPage = 5
  // Index Pages
  Object.keys(allPosts).forEach(key => {
    const totalPages = Math.ceil(allPosts[key].totalCount / postsPerPage)
    for (let i = 0; i < totalPages; i++) {
      createPage({
        path: i === 0 ? `/${key}/` : `/${key}/page/${i + 1}/`,
        component: require.resolve(`./src/templates/index.js`),
        context: {
          language: key,
          skip: postsPerPage * i,
          limit: postsPerPage,
          currentPage: i + 1, //current page number
          isFirst: i + 1 === 1, //if it's the first page
          isLast: i + 1 === totalPages, //if it's the last page
          pages: totalPages,
        },
      })
    }
  })
}

How to determine the current display language and translated articles

This section describes creation on individual post article pages. The same concept (obtaining the display language) can be used to generate articles list pages.

Getting the display language

In the page generation of gatsby-node.js mentioned earlier, I added language to the context property to send the language code as a value.

These will be available in the template as pageContext to retrieve the data.

src/template/post.js
const SinglePost = ( { pageContext } ) => {
  const currentLang = pageContext.language

  return (
    //...
  )
}

This value can be sent to each component and used to toggle the display by language in the header and footer.

Checking for Translated Articles

You can check if there are translations of the article currently displayed, also in the template.

The gatsby-node.js page generation includes the slug of the generated page in the context property, so the matching post with the slug can be retrieved from allMarkdownRemark.

The retrieved query is expanded in jsx by map() and stored as an array in the constant name availLangs.

src/template/post.js
const SinglePost = ( { pageContext, data } ) => {
  const currentLang = pageContext.language

  const availLangs = data.allMarkdownRemark.nodes.map(
    node => node.fields.language
  )

  return (
    //...
  )
}

export const query = graphql`
  query($id: String!, $slug: String!) {
    markdownRemark(id: { eq: $id }) {
      ... // Current post data
    }
    allMarkdownRemark(
      filter: { fields: { slug: { eq: $slug } } }
      sort: { fields: { language: ASC } }
    ) {
      nodes {
        id
        fields {
          language
        }
      }
    }
  }
`

This way, the availLangs are sent to the language switcher component and the link is displayed in the switcher only if a translation is available.

*I will not go into the creation of the language switcher here.

Adding lang attribute to html tags in Gatsby Head API

Gatsby.js allows you to send dynamic data to <html> and <body> tags by using Gatsby Head API in each template.

As with the JSX description of the template, the current language is retrieved from the pageContext and the language attribute of the <html> tag can be specified.

src/template/post.js
export const Head = ({ pageContext }) => {
  const currentLang = pageContext.language

  return <html lang={currentLang} />
}

Link - Gatsby Head API

For the articles list page

In the case of articles list pages, such as the top page and tag pages, the current display language is passed to the context property in gatsby-node.js, so they can be created in the same way using the above approach.

Creating the top page and the 404 page in different languages 

Redirection process to the top page

The top page for each language was created in gatsby-node.js earlier for each language, but the root domain URL needs to be handled.

When I was creating it with Next.js, https://route360.dev/ was the English top page. Therefore now, if this URL was accessed, it would redirect to https://route360.dev/en/.

The hosting is Cloudflare Pages. The redirection setting is as follows. *The same configuration should work for Netlify.

src/static/_redirects
/ /en 301

Displaying the 404 page

Since Cloudflare Pages does not support custom redirects (Netlify does), I made the 404 page like this; (the actual code below is modified for illustrative purposes)

src/pages/404.js
const NotFoundPage = ({ location }) => {
  const browserLang = location.pathname.slice(1, 3)

  const languageMap = {
    ja: "ja",
    fr: "fr",
    en: "en",
  }

  const backToHome = {
    en: "Back to Home",
    fr: "Retour à la page d'accueil",
    ja: "ホームに戻る",
  }

  let currentLang = languageMap[browserLang] || languageMap["en"]

  return (
    <Layout currentLang={currentLang}>
      <div className={classes.postsContainer}>
        <p>404 Not Found</p>
        <Link to={`/${currentLang}/`}>{backToHome[currentLang]}</Link>
      </div>
    </Layout>
  )
}

If the path to the 404 page includes a language code such as /en/, I display "Return to Home" in that language. If it doesn't, it's in English.

*With the above evaluation, if the second and third characters are en like length, it will be evaluated as English. If you want to evaluate the path more strictly, please rewrite the code accordingly.

Implementing language-specific meta tags in headers (for SEO)

For SEO purposes, if there are translated pages, add code in the <head> tag regarding availability of translations and default language pages.

In this site, we send availLangs data in the SEO component through the Gatsby Head API and add the following data only if there is a translation/translations.

Notifying Google about the localized version of the page

<link rel="alternate" hreflang="language_code" href="current_url" />

Everything is explained in Google's Methods for indicating your alternate pages guide.

Metadata for OGP (Open Graph Protocol)

<meta property="og:locale:alternate" content="language_code" />

Also, add metadata for OGP.

Creating RSS feeds by language

Use gatsby-plugin-feed to generate RSS feeds for each language.

How to create a language-specific RSS feed with the plugin is explained in the following entry:

Creating language-specific RSS feeds for a multilingual Gatsby site

Creating an XML Sitemap for a Multilingual Website

Gatsby's official gatsby-plugin-sitemap can generate an XML sitemap, but it must be optimized for multilingual websites.

How to generate a custom XML sitemap with the plugin is explained in the following entry:

Generating a custom XML sitemap for a multilingual Gatsby site

Summary

With good use of gatsby-node.js path creation, I was able to create a multilingual site with Gatsby.js, without using any special plug-ins for i18n.

When I created a multilingual site with Astro, the official Astro multilingual documentation helped me a lot to understand the structure. The concept worked and was applied again.

It seems that right-to-left languages (e.g., Arabic) would also be okay if the layout component is separated by language.

That's it.