Gatsby Headaches And How To Cure Them: i18n (Part 2)

Welcome back to “Gatsby Headaches and How to Cure Them,” a series of articles where we solve the most common challenges that arise when developing with Gatsby. Today, we are in Part 2 on implementing internationalization (i18n) on a Gatsby website, an essential feature on any multilingual website that can be quite a bit of trouble if incorrectly implemented, creating more problems than it solves. In this case, we will see how to add i18n to a Gatsby site without resorting to any plugins.

In Part 1 of this series, we peeked at how to add i18n to a Gatsby blog using a motley set of Gatsby plugins. They are great if you know what they can do, how to use them, and how they work. Still, plugins don’t always work great together since they are often written by different developers, which can introduce compatibility issues and cause an even bigger headache. Besides, we usually use plugins for more than i18n since we also want to add features like responsive images, Markdown support, themes, CMSs, and so on, which can lead to a whole compatibility nightmare if they aren’t properly supported.

How can we solve this? Well, when working with an incompatible, or even an old, plugin, the best solution often involves finding another plugin, hopefully one that provides better support for what is needed. Otherwise, you could find yourself editing the plugin’s original code to make it work (an indicator that you are in a bad place because it can introduce breaking changes), and unless you want to collaborate on the plugin’s codebase with the developers who wrote it, it likely won’t be a permanent solution.

But there is another option!

Table of Contents

Note: Here is the Live Demo.

The Solution: Make Your Own Plugin!

Sure, that might sound intimidating, but adding i18n from scratch to your blog is not so bad once you get down to it. Plus, you gain complete control over compatibility and how it is implemented. That’s exactly what we are going to do in this article, specifically by adding i18n to the starter site — a cooking blog — that we created together in Part 1.

The Starter

You can go ahead and see how we made our cooking blog starter in Part 1 or get it from GitHub.

This starter includes a homepage, blog post pages created from Markdown files, and blog posts authored in English and Spanish.

What we will do is add the following things to the site:

  • Localized routes for the home and blog posts,
  • A locale selector,
  • Translations,
  • Date formatting.

Let’s go through each one together.

More after jump! Continue reading below ↓

Create Localized Routes

First, we will need to create a localized route for each locale, i.e., route our English pages to paths with a /en/ prefix and the Spanish pages to a path with a /es/ prefix. So, for example, a path like will be replaced with localized routes, like for English and for Spanish.

In Part 1, we used the gatsby-theme-i18n plugin to automatically add localized routes for each page, and it worked perfectly. However, to make our own version, we first must know what happens underneath the hood of that plugin.

What gatsby-theme-i18n does is modify the createPages process to create a localized version of each page. However, what exactly is createPages?

How Plugins Create Pages

When running npm run build in a fresh Gatsby site, you will see in the terminal what Gatsby is doing, and it looks something like this:

success open and validate gatsby-configs - 0.062 s
success load plugins - 0.915 s
success onPreInit - 0.021 s
success delete html and css files from previous builds - 0.030 s
success initialize cache - 0.034 s
success copy gatsby files - 0.099 s
success onPreBootstrap - 0.034 s
success source and transform nodes - 0.121 s
success Add explicit types - 0.025 s
success Add inferred types - 0.144 s
success Processing types - 0.110 s
success building schema - 0.365 s
success createPages - 0.016 s
success createPagesStatefully - 0.079 s
success onPreExtractQueries - 0.025 s
success update schema - 0.041 s
success extract queries from components - 0.333 s
success write out requires - 0.020 s
success write out redirect data - 0.019 s
success Build manifest and related icons - 0.141 s
success onPostBootstrap - 0.164 s
info bootstrap finished - 6.932 s
success run static queries - 0.166 s — 3/3 20.90 queries/second
success Generating image thumbnails — 6/6 - 1.059 s
success Building production JavaScript and CSS bundles - 8.050 s
success Rewriting compilation hashes - 0.021 s
success run page queries - 0.034 s — 4/4 441.23 queries/second
success Building static HTML for pages - 0.852 s — 4/4 23.89 pages/second
info Done building in 16.143999152 sec

As you can see, Gatsby does a lot to ship your React components into static files. In short, it takes five steps:

  1. Source the node objects defined by your plugins on gatsby-config.js and the code in gatsby-node.js.
  2. Create a schema from the nodes object.
  3. Create the pages from your /src/page JavaScript files.
  4. Run the GraphQL queries and inject the data on your pages.
  5. Generate and bundle the static files into the public directory.

And, as you may notice, plugins like gatsby-theme-i18n intervene in step three, specifically when pages are created on createPages:

success createPages - 0.016 s

How exactly does gatsby-theme-i18n access createPages? Well, Gatsby exposes an onCreatePage event handler on the gatsby-node.js to read and modify pages when they are being created.

Learn more about creating and modifying pages and the Gatsby building process over at Gatsby’s official documentation.

Using onCreatePage

The createPages process can be modified in the gatsby-node.js file through the onCreatePage API. In short, onCreatePage is a function that runs each time a page is created by Gatsby. Here’s how it looks:

// ./gatsby-node.js
exports.onCreatePage = ({ page, actions }) => { const { createPage, deletePage } = actions; // etc.

It takes two parameters inside an object:

  • page holds the information of the page that’s going to be created, including its context, path, and the React component associated with it.
  • actions holds several methods for editing the site’s state. In the Gatsby docs, you can see all available methods. For this example we’re making, we will be using two methods: createPage and deletePage, both of which take a page object as the only parameter and, as you might have deduced, they create or delete the page.

So, if we wanted to add a new context to all pages, it would translate to deleting the pages being created and replacing them with new ones that have the desired context:

exports.onCreatePage = ({ page, actions }) => { const { createPage, deletePage } = actions; deletePage(page); createPage({, context: {, category: `vegan`, }, });

Creating The Pages

Since we need to create English and Spanish versions of each page, it would translate to deleting every page and creating two new ones, one for each locale. And to differentiate them, we will assign them a localized route by adding the locale at the beginning of their path.

Let’s start by creating a new gatsby-node.js file in the project’s root directory and adding the following code:

// ./gatsby-node.js const locales = ["en", "es"]; exports.onCreatePage = ({page, actions}) => { const {createPage, deletePage} = actions; deletePage(page); locales.forEach((locale) => { createPage({, path: `${locale}${page.path}`, }); });

Note: Restarting the development server is required to see the changes.

Now, if we go to http://localhost:8000/en/ or http://localhost:8000/es/, we will see all our content there. However, there is a big caveat. Specifically, if we head back to the non-localized routes — like http://localhost:8000/ or http://localhost:8000/recipes/mac-and-cheese/ — Gatsby will throw a runtime error instead of the usual 404 page provided by Gatsby. This is because we deleted our 404 page in the process of deleting all of the other pages!

Well, the 404 page wasn’t exactly deleted because we can still access it if we go to http://localhost:8000/en/404 or http://localhost:8000/es/404. However, we deleted the original 404 page and created two localized versions. Now Gatsby doesn’t know they are supposed to be 404 pages.

To solve it, we need to do something special to the 404 pages at onCreatePage.

Besides a path, every page object has another property called matchPath that Gatsby uses to match the page on the client side, and it is normally used as a fallback when the user reaches a non-existing page. For example, a page with a matchPath property of /recipes/* (notice the wildcard *) will be displayed on each route at that doesn’t have a page. This is useful for making personalized 404 pages depending on where the user was when they reached a non-existing page. For instance, social media could display a usual 404 page on but display an empty profile page on In this case, we want to display a localized 404 page depending on whether or not the user was on or

The good news is that we can modify the matchPath property on the 404 pages:

// gatsby-node.js const locales = [ "en", "es" ]; exports.onCreatePage = ({ page, actions }) => { const { createPage, deletePage } = actions; deletePage(page); locales.forEach((locale) => { const matchPath = page.path.match(/^\/404\/$/) ? (locale === "en" ? `/*` : `/${locale}/*`) : page.matchPath; createPage({, path: `${locale}${page.path}`, matchPath, }); });

This solves the problem, but what exactly did we do in matchpath? The value we are assigning to the matchPath is asking:

  • Is the page path /404/?
    • No: Leave it as-is.
    • Yes:
      • Is the locale in English?
        • Yes: Set it to match any route.
        • No: Set it to only match routes on that locale.

This results in the English 404 page having a matchPath of /*, which will be our default 404 page; meanwhile, the Spanish version will have matchPath equal /es/* and will only be rendered if the user is on a route that begins with /es/, e.g., Now, if we restart the server and head to a non-existing page, we will be greeted with our usual 404 page.

Besides fixing the runtime error, doing leave us with the possibility of localizing the 404 page, which we didn’t achieve in Part 1 with the gatsby-theme-i18n plugin. That’s already a nice improvement we get by not using a plugin!

Querying Localized Content

Now that we have localized routes, you may notice that both http://localhost:8000/en/ and http://localhost:8000/es/ are querying English and Spanish blog posts. This is because we aren’t filtering our Markdown content on the page’s locale. We solved this in Part 1, thanks to gatsby-theme-i18n injecting the page’s locale on the context of each page, making it available to use as a query variable on the GraphQL query.

In this case, we can also add the locale into the page’s context in the createPage method:

// gatsby-node.js const locales = [ "en", "es" ]; exports.onCreatePage = ({page, actions}) => { const { createPage, deletePage } = actions; deletePage(page); locales.forEach((locale) => { const matchPath = page.path.match(/^\/404\/$/) ? (locale === "en" ? `/*` : `/${locale}/*`) : page.matchPath; createPage({, path: `${locale}${page.path}`, context: {, locale, }, matchPath, }); });

Note: Restarting the development server is required to see the changes.

From here, we can filter the content on both the homepage and blog posts, which we explained thoroughly in Part 1. This is the index page query:

query IndexQuery($locale: String) { allMarkdownRemark(filter: {frontmatter: {locale: {eq: $locale}}}) { nodes { frontmatter { slug title date cover_image { image { childImageSharp { gatsbyImageData } } alt } } } }

And this is the {markdownRemark.frontmatter__slug}.js page query:

query RecipeQuery($frontmatter__slug: String, $locale: String) { markdownRemark(frontmatter: {slug: {eq: $frontmatter__slug}, locale: {eq: $locale}}) { frontmatter { slug title date cover_image { image { childImageSharp { gatsbyImageData } } alt } } html }

Now, if we head to http://localhost:8000/en/ or http://localhost:8000/es/, we will only see our English or Spanish posts, depending on which locale we are on.

However, if we try to click on any recipe, it will take us to a 404 page since the links are still pointing to the non-localized recipes. In Part 1, gatsby-theme-i18n gave us a LocalizedLink component that worked exactly like Gatsby’s Link but pointed to the current locale, so we will have to create a LocalizedLink component from scratch. Luckily is pretty easy, but we will have to make some preparation first.

Setting Up A Locale Context

For the LocalizedLink to work, we will need to know the page’s locale at all times, so we will create a new context that holds the current locale, then pass it down to each component. We can implement it on wrapPageElement in the gatsby-browser.js and gatsby-ssr.js Gatsby files. The wrapPageElement is the component that wraps our entire page element. However, remember that Gatsby recommends setting context providers inside wrapRootElement, but in this case, only wrapPageEement can access the page’s context where the current locale can be found.

Let’s create a new directory at ./src/context/ and add a LocaleContext.js file in it with the following code:

// ./src/context/LocaleContext.js import * as React from "react";
import { createContext } from "react"; export const LocaleContext = createContext();
export const LocaleProvider = ({ locale, children }) => { return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;

Next, we will set the page’s context at gatsby-browser.js and gatsby-ssr.js and pass it down to each component:

// ./gatsby-browser.js & ./gatsby-ssr.js import * as React from "react";
import { LocaleProvider } from "./src/context/LocaleContext"; export const wrapPageElement = ({ element }) => { const {locale} = element.props.pageContext; return <LocaleProvider locale={locale}>{element}</LocaleProvider>;

Note: Restart the development server to load the new files.

Now let’s make sure that the locale is available in the LocalizedLink component, which we will create in the ./src/components/LocalizedLink.js file:

// ./src/components/LocalizedLink.js import * as React from "react";
import { useContext } from "react";
import { Link } from "gatsby";
import { LocaleContext } from "../context/LocaleContext"; export const LocalizedLink = ({ to, children }) => { const locale = useContext(LocaleContext); return <Link to={`/${locale}${to}`}>{children}</Link>;

We can use our LocalizedLink at RecipePreview.js and 404.js just by changing the imports:

// ./src/components/RecipePreview.js import * as React from "react";
import { LocalizedLink as Link } from "./LocalizedLink";
import { GatsbyImage, getImage } from "gatsby-plugin-image"; export const RecipePreview = ({ data }) => { const { cover_image, title, slug } = data; const cover_image_data = getImage(cover_image.image.childImageSharp.gatsbyImageData); return ( <Link to={`/recipes/${slug}`}> <h1>{title}</h1> <GatsbyImage image={cover_image_data} alt={cover_image.alt} /> </Link> );
// ./src/pages/404.js import * as React from "react";
import { LocalizedLink as Link } from "../components/LocalizedLink"; const NotFoundPage = () => { return ( <main> <h1>Page not found</h1> <p> Sorry 😔 We were unable to find what you were looking for. <br /> <Link to="/">Go Home</Link>. </p> </main> );
}; export default NotFoundPage;
export const Head = () => <title>Not Found</title>;

Redirecting Users

As you may have noticed, we deleted the non-localized pages and replaced them with localized ones, but by doing so, we left the non-localized routes empty with a 404 page. As we did in Part 1, we can solve this by setting up redirects at gatbsy-node.js to take users to the localized version. However, this time we will create a redirect for each page instead of creating a redirect that covers all pages.

These are the redirects from Part 1:

// ./gatsby-node.js exports.createPages = async ({ actions }) => { const { createRedirect } = actions; createRedirect({ fromPath: `/*`, toPath: `/en/*`, isPermanent: true, }); createRedirect({ fromPath: `/*`, toPath: `/es/*`, isPermanent: true, conditions: { language: [`es`], }, });
}; // etc.

These are the new localized redirects:

// ./gatsby-node.js exports.onCreatePage = ({ page, actions }) => { // Create localize version of pages... const { createRedirect } = actions; createRedirect({ fromPath: page.path, toPath: `/en${page.path}`, isPermanent: true, }); createRedirect({ fromPath: page.path, toPath: `/es${page.path}`, isPermanent: true, conditions: { language: [`es`], }, });
}; // etc.

We won’t see the difference right away since redirects don’t work in development, but if we don’t create a redirect for each page, the localized 404 pages won’t work in production. We didn’t have to do this same thing in Part 1 since gatsby-theme-i18n didn’t localize the 404 page the way we did.

Changing Locales

Another vital feature to add is a language selector component to toggle between the two locales. However, making a language selector isn’t completely straightforward because:

  1. We need to know the current page’s path, like /en/recipes/pizza,
  2. Then extract the recipes/pizza part, and
  3. Add the desired locale, getting /es/recipes/pizza.

Similar to Part 1, we will have to access the page’s location information (URL, HREF, path, and so on) in all of our components, so it will be necessary to set up another context provider at the wrapPageElement function to pass down the location object through context on each page. A deeper explanation can be found in Part 1.

Setting Up A Location Context

First, we will create the location context at ./src/context/LocationContext.js:

// ./src/context/LocationContext.js import * as React from "react";
import { createContext } from "react"; export const LocationContext = createContext();
export const LocationProvider = ({ location, children }) => { return <LocationContext.Provider value={location}>{children}</LocationContext.Provider>;

Next, let’s pass the page’s location object to the provider’s location attribute on each Gatsby file:

// ./gatsby-ssr.js & ./gatsby-browser.js import * as React from "react";
import { LocaleProvider } from "./src/context/LocaleContext";
import { LocationProvider } from "./src/context/LocationContext"; export const wrapPageElement = ({ element, props }) => { const { location } = props; const { locale } = element.props.pageContext; return ( <LocaleProvider locale={locale}> <LocationProvider location={location}>{element}</LocationProvider> </LocaleProvider> );

Creating An i18n Config

For the next step, it will come in handy to create a file with all our i18n details, such as the locale code or the local name. We can do it in a new config.js file in a new i18n/ directory in the root directory of the project.

// ./i18n/config.js export const config = [ { code: "en", hrefLang: "en-US", name: "English", localName: "English", }, { code: "es", hrefLang: "es-ES", name: "Spanish", localName: "Español", },

The LanguageSelector Component

The last thing is to remove the locale (i.e., es or en) from the path (e.g., /es/recipes/pizza or /en/recipes/pizza). Using the following simple but ugly regex, we can remove all the /en/ and /es/ at the beginning of the path:


It’s important to note that the regex pattern only works for the en and es combination of locales.

Now we can create our LanguageSelector component at ./src/components/LanguageSelector.js:

// ./src/components/LanguageSelector.js import * as React from "react";
import { useContext } from "react";
// 1
import { config } from "../../i18n/config";
import { Link } from "gatsby";
import { LocationContext } from "../context/LocationContext";
import { LocaleContext } from "../context/LocaleContext"; export const LanguageSelector = () => {
// 2 const locale = useContext(LocaleContext);
// 3 const { pathname } = useContext(LocationContext);
// 4 const removeLocalePath = /(\/e(s|n)|)(\/*|)/; const pathnameWithoutLocale = pathname.replace(removeLocalePath, "");
// 5 return ( <div> {{code, localName}) => { return ( code !== locale && ( <Link key={code} to={`/${code}/${pathnameWithoutLocale}`}> {localName} </Link> ) ); }) } </div>

Let’s break down what is happening in that code:

  1. We get our i18n configurations from the ./i18n/config.js file instead of the useLocalization hook that was provided by the gatsby-theme-i18n plugin in Part 1.
  2. We get the current locale through context.
  3. We find the page’s current pathname through context, which is the part that comes after the domain (e.g., /en/recipes/pizza).
  4. We remove the locale part of the pathname using the regex pattern (leaving just recipes/pizza).
  5. We render a link for each available locale except the current one. So we check if the locale is the same as the page before rendering a common Gatsby Link to the desired locale.

Now, inside our gatsby-ssr.js and gatsby-browser.js files, we can add our LanguageSelector, so it is available globally on the site at the top of all pages:

// ./gatsby-ssr.js & ./gatsby-browser.js import * as React from "react";
import { LocationProvider } from "./src/context/LocationContext";
import { LocaleProvider } from "./src/context/LocaleContext";
import { LanguageSelector } from "./src/components/LanguageSelector"; export const wrapPageElement = ({ element, props }) => { const { location } = props; const { locale } = element.props.pageContext; return ( <LocaleProvider locale={locale}> <LocationProvider location={location}> <LanguageSelector /> {element} </LocationProvider> </LocaleProvider> );

Localizing Static Content

The last thing to do would be to localize the static content on our site, like the page titles and headers. To do this, we will need to save our translations in a file and find a way to display the correct one depending on the page’s locale.

Page Body Translations

In Part 1, we used the react-intl package for adding our translations, but we can do the same thing from scratch. First, we will need to create a new translations.js file in the /i18n folder that holds all of our translations.

We will create and export a translations object with two properties: en and es, which will hold the translations as strings under the same property name.

// ./i18n/translations.js export const translations = { en: { index_page_title: "Welcome to my English cooking blog!", index_page_subtitle: "Written by Juan Diego Rodríguez", not_found_page_title: "Page not found", not_found_page_body: "😔 Sorry, we were unable find what you were looking for.", not_found_page_back_link: "Go Home", }, es: { index_page_title: "¡Bienvenidos a mi blog de cocina en español!", index_page_subtitle: "Escrito por Juan Diego Rodríguez", not_found_page_title: "Página no encontrada", not_found_page_body: "😔 Lo siento, no pudimos encontrar lo que buscabas", not_found_page_back_link: "Ir al Inicio", },

We know the page’s locale from the LocaleContext we set up earlier, so we can load the correct translation using the desired property name.

The cool thing is that no matter how many translations we add, we won’t bloat our site’s bundle size since Gatsby builds the entire app into a static site.

// ./src/pages/index.js // etc. import { LocaleContext } from "../context/LocaleContext";
import { useContext } from "react";
import { translations } from "../../i18n/translations"; const IndexPage = ({ data }) => { const recipes = data.allMarkdownRemark.nodes; const locale = useContext(LocaleContext); return ( <main> <h1>{translations[locale].index_page_title}</h1> <h2>{translations[locale].index_page_subtitle}</h2> {{frontmatter}) => { return <RecipePreview key={frontmatter.slug} data={frontmatter} />; })} </main> );
}; // etc.
// ./src/pages/404.js // etc. import { LocaleContext } from "../context/LocaleContext";
import { useContext } from "react";
import { translations } from "../../i18n/translations"; const NotFoundPage = () => { const locale = useContext(LocaleContext); return ( <main> <h1>{translations[locale].not_found_page_title}</h1> <p> {translations[locale].not_found_page_body} <br /> <Link to="/">{translations[locale].not_found_page_back_link}</Link>. </p> </main> );
}; // etc.

Note: Another way we can access the locale property is by using pageContext in the page props.

Page Title Translations

We ought to localize the site’s page titles the same way we localized our page content. However, in Part 1, we used react-helmet for the task since the LocaleContext isn’t available at the Gatsby Head API. So, to complete this task without resorting to a third-party plugin, we will take a different path. We’re unable to access the locale through the LocaleContext, but as I noted above, we can still get it with the pageContext property in the page props.

// ./src/page/index.js // etc. export const Head = ({pageContext}) => { const {locale} = pageContext; return <title>{translations[locale].index_page_title}</title>;
}; // etc.
// ./src/page/404.js // etc. export const Head = ({pageContext}) => { const {locale} = pageContext; return <title>{translations[locale].not_found_page_title}</title>;
}; // etc.


Remember that i18n also covers formatting numbers and dates depending on the current locale. We can use the Intl object from the JavaScript Internationalization API. The Intl object holds several constructors for formatting numbers, dates, times, plurals, and so on, and it’s globally available in JavaScript.

In this case, we will use the Intl.DateTimeFormat constructor to localize dates in blog posts. It works by creating a new Intl.DateTimeFormat object with the locale as its parameter:

const DateTimeFormat = new Intl.DateTimeFormat("en");

The new Intl.DateTimeFormat and other Intl instances have several methods, but the main one is the format method, which takes a Date object as a parameter.

const date = new Date();
console.log(new Intl.DateTimeFormat("en").format(date)); // 4/20/2023
console.log(new Intl.DateTimeFormat("es").format(date)); // 20/4/2023

The format method takes an options object as its second parameter, which is used to customize how the date is displayed. In this case, the options object has a dateStyle property to which we can assign "full", "long", "medium", or "short" values depending on our needs:

const date = new Date(); console.log(new Intl.DateTimeFormat("en", {dateStyle: "short"}).format(date)); // 4/20/23
console.log(new Intl.DateTimeFormat("en", {dateStyle: "medium"}).format(date)); // Apr 20, 2023
console.log(new Intl.DateTimeFormat("en", {dateStyle: "long"}).format(date)); // April 20, 2023
console.log(new Intl.DateTimeFormat("en", {dateStyle: "full"}).format(date)); // Thursday, April 20, 2023

In the case of our blog posts publishing date, we will set the dateStyle to "long".

// ./src/pages/recipes/{markdownRemark.frontmatter__slug}.js // etc. const RecipePage = ({ data, pageContext }) => { const { html, frontmatter } = data.markdownRemark; const { title, cover_image, date } = frontmatter; const { locale } = pageContext; const cover_image_data = getImage(cover_image.image.childImageSharp.gatsbyImageData); return ( <main> <h1>{title}</h1> <p>{new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(new Date(date))}</p> <GatsbyImage image={cover_image_data} alt={cover_image.alt} /> <p dangerouslySetInnerHTML={{__html: html}}></p> </main> );
}; // etc.


And just like that, we reduced the need for several i18n plugins to a grand total of zero. And we didn’t even lose any functionality in the process! If anything, our hand-rolled solution is actually more robust than the system of plugins we cobbled together in Part 1 because we now have localized 404 pages.

That said, both approaches are equally valid, but in times when Gatsby plugins are unsupported in some way or conflict with other plugins, it is sometimes better to create your own i18n solution. That way, you don’t have to worry about plugins that are outdated or left unmaintained. And if there is a conflict with another plugin, you control the code and can fix it. I’d say these sorts of benefits greatly outweigh the obvious convenience of installing a ready-made, third-party solution.

Smashing Editorial
(gg, yk, il)