elcome 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.
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!
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:
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
:
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:
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:
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:
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:
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:
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:
And this is the {markdownRemark.frontmatter__slug}.js
page query:
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:
Next, we will set the page’s context at gatsby-browser.js
and gatsby-ssr.js
and pass it down to each component:
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:
We can use our LocalizedLink
at RecipePreview.js
and 404.js
just by changing the imports:
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:
These are the new localized redirects:
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
:
Next, let’s pass the page’s location
object to the provider’s location
attribute on each Gatsby file:
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.
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
:
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.
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.
“
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
.
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:
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.
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:
In the case of our blog posts publishing date, we will set the dateStyle
to "long"
.
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.