+1 (415) 779-8456

Multilingual Gatsby Without a Dead Plugin: Locale Routing, hreflang, and a Migration-Safe Content Model

Multilingual work is where Gatsby sites age worst. The framework never shipped an official i18n story, so every site picked one of three community approaches, and two of them are now unmaintained. If you are running a multi-locale Gatsby site in 2026 — or planning to add locales, or planning to move the site to Astro or Next.js later — the decision that matters is not which plugin you install. It is how locale is represented in your content and your routes.

This tutorial builds locale routing on Gatsby 5 without a dependency on an abandoned plugin, wires up correct hreflang and canonical tags, and shows the content model that survives a replatform.

The three approaches, and which one to keep

gatsby-plugin-intl and friends. These generate a copy of every page per locale by hooking onCreatePage, and wrap the app in a React context. They work, but they own your routing, they fight with the Head API, and most of them have not had a release in years. If you are on one and it still builds, fine — but do not start here.

gatsby-theme-i18n. Better model (locale lives in file paths), also effectively dormant.

Locale in the content model, routes created by you in gatsby-node.js. About sixty lines of code, no dependency, and it maps one-to-one onto Astro's [lang] directories and Next.js's [locale] segment. This is what we migrate clients to, and what the rest of this tutorial does.

Step 1: put locale in the source, not in a runtime context

Directory-per-locale is the model that ports cleanly:

content/
  en/
    pricing.md
    blog/gatsby-builds.md
  de/
    pricing.md
    blog/gatsby-builds.md
  fr/
    pricing.md

Note that fr is missing the blog post. That is normal and your build must handle it rather than crash or silently emit a German page at a French URL.

Derive locale and a locale-independent translationKey in onCreateNode:

// gatsby-node.js
const path = require('node:path');

const DEFAULT_LOCALE = 'en';
const LOCALES = ['en', 'de', 'fr'];

exports.onCreateNode = ({ node, actions, getNode }) => {
  if (node.internal.type !== 'MarkdownRemark') return;
  const fileNode = getNode(node.parent);
  const relative = fileNode.relativePath; // e.g. "de/blog/gatsby-builds.md"
  const [locale, ...rest] = relative.split('/');
  if (!LOCALES.includes(locale)) {
    throw new Error(`Content file outside a locale directory: ${relative}`);
  }
  const key = rest.join('/').replace(/\.mdx?$/, '').replace(/\/index$/, '');

  actions.createNodeField({ node, name: 'locale', value: locale });
  actions.createNodeField({ node, name: 'translationKey', value: key });
  actions.createNodeField({
    node,
    name: 'slug',
    value: locale === DEFAULT_LOCALE ? `/${key}/` : `/${locale}/${key}/`,
  });
};

translationKey is the load-bearing field. Every alternate-language link, language switcher, and hreflang tag below is a lookup on it. Deriving it from the path means it cannot drift out of sync the way a hand-maintained frontmatter translations: [...] array does.

The default locale at the root (/pricing/) with others prefixed (/de/pricing/) is the conventional choice and keeps existing English URLs stable. Prefixing every locale including the default (/en/pricing/) is also defensible, but only if you are willing to redirect the old URLs and you accept the redirect hop.

Step 2: create pages, and pass the translation set in context

exports.createPages = async ({ graphql, actions, reporter }) => {
  const { data, errors } = await graphql(`
    {
      allMarkdownRemark {
        nodes {
          id
          fields { slug locale translationKey }
        }
      }
    }
  `);
  if (errors) { reporter.panicOnBuild('i18n page query failed', errors); return; }

  const byKey = new Map();
  for (const node of data.allMarkdownRemark.nodes) {
    const list = byKey.get(node.fields.translationKey) || [];
    list.push({ locale: node.fields.locale, path: node.fields.slug });
    byKey.set(node.fields.translationKey, list);
  }

  for (const node of data.allMarkdownRemark.nodes) {
    actions.createPage({
      path: node.fields.slug,
      component: path.resolve('./src/templates/page.js'),
      context: {
        id: node.id,
        locale: node.fields.locale,
        translationKey: node.fields.translationKey,
        translations: byKey.get(node.fields.translationKey),
      },
    });
  }
};

Everything the page needs to render language links and hreflang is now in build-time context. No client-side locale detection, no redirect on first paint, no layout shift while a context resolves.

Step 3: hreflang and canonical, rendered into static HTML

This is the part that is usually wrong. The rules are narrow:

  • Every page in a translation set links to every page in the set, including itself.
  • hreflang values are language or language-region codes (de, pt-BR), not country codes. hreflang="uk" means Ukrainian, not the United Kingdom.
  • URLs in hreflang must be absolute and must be the canonical URL of the target, not a redirect.
  • x-default points at whatever a user with no matching language should get.
  • The hreflang set must be reciprocal. If /de/pricing/ claims /pricing/ as its English alternate but the English page does not link back, search engines discard the whole cluster.
// src/templates/page.js
export const Head = ({ pageContext, data }) => {
  const siteUrl = 'https://www.example.com';
  const { locale, translations } = pageContext;
  const self = translations.find((t) => t.locale === locale);
  const fallback = translations.find((t) => t.locale === 'en') || self;
  return (
    <>
      <html lang={locale} />
      <title>{data.markdownRemark.frontmatter.title}</title>
      <link rel="canonical" href={`${siteUrl}${self.path}`} />
      {translations.map((t) => (
        <link key={t.locale} rel="alternate" hrefLang={t.locale} href={`${siteUrl}${t.path}`} />
      ))}
      <link rel="alternate" hrefLang="x-default" href={`${siteUrl}${fallback.path}`} />
    </>
  );
};

Gatsby 5's Head API supports <html lang> as a child, which is the cleanest way to get the attribute right per page — a hardcoded lang="en" in html.js is one of the most common findings in the accessibility audits we run on multilingual sites, and screen readers really do switch voice on it.

Assert reciprocity in CI rather than trusting review:

// scripts/check-hreflang.js — run after `gatsby build`
const { globSync } = require('glob');
const fs = require('node:fs');

let failures = 0;
const pages = new Map();
for (const file of globSync('public/**/index.html')) {
  const html = fs.readFileSync(file, 'utf8');
  const alts = [...html.matchAll(/rel="alternate"\s+hrefLang="([^"]+)"\s+href="([^"]+)"/gi)]
    .map(([, lang, href]) => ({ lang, href }));
  const canonical = html.match(/rel="canonical"\s+href="([^"]+)"/i)?.[1];
  if (canonical) pages.set(canonical, alts);
}
for (const [url, alts] of pages) {
  if (!alts.length) continue;
  if (!alts.some((a) => a.href === url)) {
    console.error(`self-referencing hreflang missing: ${url}`);
    failures++;
  }
  for (const alt of alts) {
    if (alt.lang === 'x-default') continue;
    const back = pages.get(alt.href);
    if (!back || !back.some((b) => b.href === url)) {
      console.error(`non-reciprocal hreflang: ${url} -> ${alt.href}`);
      failures++;
    }
  }
}
process.exit(failures ? 1 : 0);

Run it in the same CI step as your build. Broken hreflang is invisible in a browser and expensive in traffic.

Step 4: UI strings without a runtime plugin

Page content comes from Markdown; the chrome — nav labels, button text, dates — does not. A JSON file per locale and a tiny hook is enough for most consulting-site-sized projects:

// src/i18n/index.js
import en from './en.json';
import de from './de.json';
import fr from './fr.json';

const messages = { en, de, fr };

export const t = (locale, key) =>
  messages[locale]?.[key] ?? messages.en[key] ?? key;

export const formatDate = (locale, iso) =>
  new Intl.DateTimeFormat(locale, { dateStyle: 'long' }).format(new Date(iso));

Use Intl for dates, numbers, currency, and plurals (Intl.PluralRules) instead of shipping a formatting library — it is in every runtime you target and it is not your code to maintain. Reach for react-intl or i18next only when you need ICU message syntax, rich-text interpolation, or translator tooling that expects those formats.

Fail the build on missing keys rather than shipping undefined into production:

// scripts/check-messages.js
const en = require('../src/i18n/en.json');
const others = { de: require('../src/i18n/de.json'), fr: require('../src/i18n/fr.json') };
let bad = 0;
for (const [locale, msgs] of Object.entries(others)) {
  for (const key of Object.keys(en)) {
    if (!(key in msgs)) { console.error(`missing ${locale}: ${key}`); bad++; }
  }
}
process.exit(bad ? 1 : 0);

Step 5: the language switcher, and what not to do

Render it from pageContext.translations, so it only offers languages that actually exist for this page and always links to the exact translated URL:

const LanguageSwitcher = ({ locale, translations }) => (
  <nav aria-label="Language">
    <ul>
      {translations.map((tr) => (
        <li key={tr.locale}>
          {tr.locale === locale ? (
            <span aria-current="true" lang={tr.locale}>{LABELS[tr.locale]}</span>
          ) : (
            <a href={tr.path} hrefLang={tr.locale} lang={tr.locale}>{LABELS[tr.locale]}</a>
          )}
        </li>
      ))}
    </ul>
  </nav>
);

Three things to avoid:

  1. Automatic redirects based on Accept-Language or IP. They break crawling, they trap users who want the English docs, and they interact badly with CDN caching. Offer a dismissible banner if you must suggest a locale.
  2. Sending every language link to the homepage of that locale. A reader on /de/pricing/ who clicks "English" wants /pricing/, not /.
  3. Flag icons for languages. Languages are not countries; this is a real accessibility and correctness problem, not a style preference. Use the endonym — Deutsch, not German.

Step 6: sitemaps and hosting

Emit one sitemap with all locale URLs and let the hreflang in the HTML do the clustering, or use gatsby-plugin-sitemap's serialize to add xhtml:link alternates. Either is acceptable; do not do both inconsistently.

{
  resolve: 'gatsby-plugin-sitemap',
  options: {
    query: `{
      allSitePage { nodes { path pageContext } }
      site { siteMetadata { siteUrl } }
    }`,
    resolveSiteUrl: ({ site }) => site.siteMetadata.siteUrl,
    resolvePages: ({ allSitePage }) => allSitePage.nodes,
    serialize: (page) => ({ url: page.path, changefreq: 'weekly', priority: 0.7 }),
  },
}

On the hosting side, the only thing multilingual builds usually need is a redirect for legacy URLs after a locale restructure, and a Vary header you are not setting — if you are serving different content at the same URL by language, you have left the static model and you should fix the URLs instead.

Why this survives a migration

Every migration target we recommend expects exactly this shape:

Gatsby (as built above)Astro 5Next.js App Router
content/<locale>/...src/content/<locale>/...content/<locale>/...
fields.locale from pathcollection entry id prefixroute segment [locale]
createPage per localegetStaticPaths with lang paramgenerateStaticParams returning { locale, slug }
translations in page contextprops from getStaticPathsdata fetched in the server component
Head API hreflang<head> in the layoutalternates.languages in generateMetadata
JSON messages + IntlJSON messages + Intl (or astro:i18n)JSON messages + Intl

A site whose locale lives in a runtime React context and in a plugin's onCreatePage hook needs that logic reverse-engineered before it can move. A site whose locale lives in the directory structure and in page context is a mechanical port — the content tree moves unchanged and the routing file gets rewritten once.

Checklist

  • Locale derived from the content path, not from frontmatter or runtime detection
  • translationKey stable across locales and asserted unique per locale
  • Missing translations degrade to a link-out, never a wrong-language page at a translated URL
  • <html lang> correct per page
  • Self-referencing, reciprocal, absolute hreflang plus x-default, verified in CI
  • Canonical points at the same-locale URL, never at the default locale
  • Language switcher links page-to-page and offers only existing translations
  • No Accept-Language auto-redirects
  • Dates, numbers, and plurals via Intl
  • Missing message keys fail the build

If you are adding locales to an existing Gatsby site, untangling one that was built on an unmaintained i18n plugin, or scoping a multilingual replatform onto Astro or Next.js, get in touch — a short review of the content model up front is usually the difference between a mechanical migration and a rewrite.