Ditching Plugins: Custom i18n in Gatsby
Relying on plugins for internationalization often leads to compatibility headaches from combining packages written by different developers. When plugins fail, the usual solution is hunting for another plugin, which risks a cycle of growing complexity. There's a cleaner path: build the i18n layer directly into your Gatsby site.
While it may seem daunting, handling i18n manually gives you complete control over both implementation and compatibility. Using the cooking blog starter from Part 1, we'll replace plugin-based i18n with custom code covering localized routes, content filtering, locale-aware links, and translated UI elements.
Understanding Gatsby's Page Creation Process
To implement i18n without plugins, we need to understand how Gatsby generates pages. Running npm run build shows the build pipeline in action:
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
The process follows five main stages:
- Source node objects from
gatsby-config.jsandgatsby-node.js. - Build a schema from the collected
nodes. - Create pages from the
/src/pageJavaScript files. - Execute GraphQL queries and inject data.
- Bundle static files into the
publicdirectory.
Plugins like gatsby-theme-i18n hook into step three, during the createPages phase:
success createPages - 0.016 s
Gatsby exposes the onCreatePage API in gatsby-node.js for intercepting page creation. This function runs on every page creation and receives two parameters:
// ./gatsby-node.js
exports.onCreatePage = ({ page, actions }) => {
const { createPage, deletePage } = actions;
// etc.
};
page— Contains the page's information including its path, context, and associated React component.actions— Provides methods likecreatePageanddeletePagefor managing pages.
Modifying pages requires deleting the original and recreating it with the desired changes:
exports.onCreatePage = ({ page, actions }) => {
const { createPage, deletePage } = actions;
deletePage(page);
createPage({
...page,
context: {
...page.context,
category: `vegan`,
},
});
};
Creating Localized Routes
For English and Spanish versions of every page, we delete the existing page and create two new ones with locale prefixes on their paths. In the root gatsby-node.js:
// ./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: Changes require restarting the development server.
Now http://localhost:8000/en/ and http://localhost:8000/es/ work correctly, but visiting non-localized paths throws a runtime error. The localizing process also created two versions of the 404 page, removing Gatsby's default behavior.
Every page object has a matchPath property used for client-side matching. Setting English's 404 to /* makes it the default, while the Spanish version uses /es/*:
// 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,
});
});
};
The logic checks if the page is /404/—if so, it sets the English version to match any route and the Spanish one only routes under /es/. This also enables localized 404 pages, something the plugin approach couldn't achieve.
Filtering Content by Locale
Both the English and Spanish routes currently display the same content because queries don't filter based on the current locale. We inject the locale into the page context during creation:
// 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,
});
});
};
With the locale available, queries on the homepage and recipes pages can filter appropriately. Homepage query:
query IndexQuery($locale: String) {
allMarkdownRemark(filter: {frontmatter: {locale: {eq: $locale}}}) {
nodes {
frontmatter {
slug
title
date
cover_image {
image {
childImageSharp {
gatsbyImageData
}
}
alt
}
}
}
}
}
Individual recipe 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
}
}
Visiting either localized homepage now shows only matching content.
Locale-Aware Linking
Recipe links still point to non-localized paths, resulting in 404 errors. Building a custom LocalizedLink component starts with a locale context.
Creating the Locale Context
The LocalizedLink component needs to know the current locale. We set up this context in gatsby-browser.js and gatsby-ssr.js using wrapPageElement, which can access page context:
Create ./src/context/LocaleContext.js first:
// ./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>;
};
Then wrap page elements with the locale context:
// ./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 after adding these files.
Building the Component
With the locale available through context, we create LocalizedLink in ./src/components/LocalizedLink.js:
// ./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>;
};
Finally, swap the standard Link imports in RecipePreview.js and 404.js:
// ./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>;
With localized routes working, content filtering in place, and LocalizedLink handling navigation, the next challenge is displaying proper translations for static UI text and formatting dates according to locale conventions.
Handling Redirects After Localization
Once the non-localized pages are replaced with localized versions, the old routes become empty and return a 404. As covered in Part 1, redirects can be set up in gatbsy-node.js to send users to the correct localized page. However, unlike the earlier approach that matched all routes at once, this setup requires a separate redirect for each page.
// ./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.
The localized redirects look like this:
// ./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.
Redirects won't be visible during development, but without per-page redirects, the localized 404 pages will not function correctly in production. This step wasn't needed in Part 1 because gatsby-theme-i18n did not localize the 404 page the same way.
Building a Language Selector
Switching between locales requires a language selector component. This involves knowing the current path (e.g., /en/recipes/pizza), extracting the route portion (recipes/pizza), and prepending the target locale to obtain /es/recipes/pizza.
Since every component needs access to the page's location data, a context provider must be added in wrapPageElement to pass the location object down. The setup mirrors the approach from Part 1, but with a dedicated context file.
Creating the Location Context
The context is defined in ./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>;
};
The page's location object is then passed 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>
);
};
Defining an i18n Configuration
To keep locale metadata organized, create a config.js file under a new i18n/ directory at the project root. This file stores details like locale codes and local names.
// ./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",
},
];
Writing the Component
To remove the locale prefix from paths like /es/recipes/pizza, a simple regex strips the leading /en/ or /es/:
/(\/e(s|n)|)(\/*|)/
Note that this pattern is specific to the en and es locale pairs only.
The LanguageSelector component lives in ./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>
);
};
Here is how it works:
- Configuration data is imported from
./i18n/config.jsrather than relying on theuseLocalizationhook fromgatsby-theme-i18n. - The current locale is read via the existing context.
- The pathname (e.g.,
/en/recipes/pizza) is obtained from the location context. - The locale portion is removed with the regex, leaving the bare route.
- A Gatsby
Linkis rendered for each locale except the active one.
Finally, the selector is mounted globally in both gatsby-ssr.js and gatsby-browser.js:
// ./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
Static text like titles and headers needs separate handling. Translations are stored in a dedicated file and selected based on the page's locale.
Content Translations
Instead of using react-intl as in Part 1, a custom translation file is created in /i18n/translations.js. This exports a translations object with en and es properties that contain parallel text strings.
// ./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",
},
};
The current locale is already available from LocaleContext, so fetching the right translation is straightforward.
The advantage of bundling all translations into a static Gatsby build is that adding more languages won't increase the site's bundle size.
// ./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: The locale can also be retrieved via the pageContext property in page props.
Title Translations and Gatsby Head
Page titles present a challenge because the LocaleContext isn't available inside the Gatsby Head API. Part 1's react-helmet approach sidesteps this, but a plugin-free method works via pageContext:
// ./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.
Localizing Dates and Numbers
i18n covers formatting for numbers and dates too. JavaScript's built-in Intl API provides constructors that handle these cases globally.
For blog post dates, Intl.DateTimeFormat is instantiated with the target locale:
const DateTimeFormat = new Intl.DateTimeFormat("en");
Its format method accepts a Date object:
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
Custom display options are passed as a second parameter. The example below uses the dateStyle property, which accepts values such as "full", "long", "medium", or "short":
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
For publication dates, set 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.
Wrapping Up
This hand-rolled i18n solution eliminates the need for third-party plugins while adding localized 404 pages that the plugin-based setup from Part 1 didn't provide. Both approaches are valid, but building your own avoids dependency maintenance and plugin conflicts. If issues arise, the code is fully under your control, which outweighs the convenience of a ready-made package.




