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.
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 my-site.com/recipes/mac-and-cheese/
will be replaced with localized routes, like my-site.com/en/recipes/mac-and-cheese/
for English and my-site.com/recipes/es/mac-and-cheese/
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:
- Source the node objects defined by your plugins on
gatsby-config.js
and the code ingatsby-node.js
. - Create a schema from the
nodes
object. - Create the pages from your
/src/page
JavaScript files. - Run the GraphQL queries and inject the data on your pages.
- 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
anddeletePage
, both of which take apage
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({ ...page, context: { ...page.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({ ...page, 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 my-site.com/recipes/
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 my-media.com/non-existing
but display an empty profile page on my-media.com/user/non-existing
. In this case, we want to display a localized 404 page depending on whether or not the user was on my-site.com/en/not-found
or my-site.com/es/not-found
.
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({ ...page, 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.
- Is the locale in English?
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., my-site.com/es/not-found
. 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({ ...page, path: `${locale}${page.path}`, context: { ...page.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.
Creating Localized Links
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.
Creating LocalizedLink
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:
- We need to know the current page’s path, like
/en/recipes/pizza
, - Then extract the
recipes/pizza
part, and - 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:
/(\/e(s|n)|)(\/*|)/
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> { config.map(({code, localName}) => { return ( code !== locale && ( <Link key={code} to={`/${code}/${pathnameWithoutLocale}`}> {localName} </Link> ) ); }) } </div>
);
};
Let’s break down what is happening in that code:
- We get our i18n configurations from the
./i18n/config.js
file instead of theuseLocalization
hook that was provided by thegatsby-theme-i18n
plugin in Part 1. - We get the current locale through context.
- We find the page’s current pathname through context, which is the part that comes after the domain (e.g.,
/en/recipes/pizza
). - We remove the locale part of the pathname using the regex pattern (leaving just
recipes/pizza
). - 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.
// ./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> {recipes.map(({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.
Formatting
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.
Conclusion
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.
(gg, yk, il)