Discoverability
Fix hreflang and canonical URLs on multilingual docs
A two-language docs site where every translation is canonical to itself and linked by hreflang, untranslated pages defer to the original, and an audit proves it.
By Hayden Bleasel8 min read

To keep translated docs from sending conflicting signals, make every real translation canonical to itself and list every translation, itself included, in its hreflang links, with an x-default for the default language. A page you haven't translated yet, served at a localized URL, should point its canonical at the original and stay out of the hreflang group and the sitemap. Blume emits all of this from your folder layout once it knows your site's URL, so most conflicts come from an override: usually a seo.canonical that a translation copied from its original.
In this guide you build a two-language fixture with one missing translation, map every URL to its language, canonical, and alternates, read the head markup Blume generates, then find and fix one broken relationship with blume audit. It checks what search engines see. To set up and write the translations themselves, start with the translation guide.
These tags tell search engines which URLs are language versions of each other, so they can show readers the one in their language. They don't make a translation rank or bring traffic on their own. If you never set seo.canonical and your translations mirror the default language's file paths, Blume's defaults are already right, and this guide is a way to confirm it. One limit: Blume pairs translations by path, so it can't link translated URL slugs like /de/anleitungen/installation to /guides/install.
What each page should say
A multilingual docs site has two kinds of localized page:
| Page | Canonical | hreflang | Sitemap |
|---|---|---|---|
| A real translation | Itself | Every real translation, itself included, plus x-default | Listed |
| A fallback copy: the original's content at a localized URL | The page it copies | Not a member of any group | Left out |
This follows Google's guidance. Each language version "must list itself as well as all other language versions", and if two pages don't point to each other, "the tags will be ignored." With hreflang, Google says to "specify a canonical page in the same language, or the best possible substitute language" when none exists in that language. Language versions count as duplicates "only if the primary content is in the same language", which describes a fallback copy exactly: English text inside German navigation.
Two limits worth knowing. Google reads a page's language from its visible content, not from lang or the URL, so hreflang groups URLs; it doesn't relabel them. And a canonical is, in Google's words, "a hint, not a rule."
Build a two-language fixture
Scaffold a project, then replace its config and pages:
npx blume init acme-docs --yes
cd acme-docsimport { defineConfig } from "blume";
export default defineConfig({
title: "Acme",
deployment: {
site: "https://docs.acme.example",
},
i18n: {
defaultLocale: "en",
locales: [
{ code: "en", label: "English" },
{ code: "de", label: "Deutsch" },
],
},
});deployment.site matters here: canonical and hreflang URLs are absolute, so a build without it emits neither. English lives at the root of docs/ and German in docs/de/. There's no German configure page, which is the missing translation:
docs/
index.mdx
guides/
install.mdx
configure.mdx
de/
index.mdx
guides/
install.mdx---
title: Acme documentation
description: Send transactional email and SMS with the Acme Messages API.
---
Start by [installing Acme](/guides/install), then [configure your account](/guides/configure).---
title: Install Acme
description: Create an API key and send your first message with the Acme Messages API.
seo:
canonical: https://docs.acme.example/guides/install
---
Create an API key in the Acme dashboard, then send a test message to your own address. Next, [configure your account](/guides/configure).---
title: Configure Acme
description: Set a sender name, a reply-to address, and a default template.
---
Set a sender name and a reply-to address before you send messages to customers.---
title: Acme-Dokumentation
description: Versende transaktionale E-Mails und SMS mit der Acme Messages API.
---
Beginne mit der [Installation](/guides/install) und [konfiguriere dann dein Konto](/guides/configure).---
title: Acme installieren
description: Erstelle einen API-Schlüssel und sende deine erste Nachricht mit der Acme Messages API.
seo:
canonical: https://docs.acme.example/guides/install
---
Erstelle im Acme-Dashboard einen API-Schlüssel und sende dann eine Testnachricht an deine eigene Adresse. Danach kannst du [dein Konto konfigurieren](/guides/configure).The English install page sets seo.canonical to its own URL. That's harmless on its own, since Blume emits the same self-canonical by default. The German page copied it along with the rest of the frontmatter. That happens when you translate by copying a file, and blume translate does it too: it rebuilds each translation's frontmatter from the source, translates only the title, description, sidebar label and badge, and SEO title and description, and copies every other key as it is, seo.canonical included.
Map every URL
Blume builds each URL from the file's path and pairs translations by that path: docs/de/guides/install.mdx is the German version of docs/guides/install.mdx because the path after de/ matches. Here's the matrix for the fixture, with paths shown relative to https://docs.acme.example (the tags themselves are absolute):
| URL | Built from | lang | Canonical | hreflang | Sitemap |
|---|---|---|---|---|---|
/ | docs/index.mdx | en | / | de, en, x-default | Yes |
/de | docs/de/index.mdx | de | /de | de, en, x-default | Yes |
/guides/install | docs/guides/install.mdx | en | /guides/install | de, en, x-default | Yes |
/de/guides/install | docs/de/guides/install.mdx | de | /guides/install | de, en, x-default | Yes |
/guides/configure | docs/guides/configure.mdx | en | /guides/configure | en, x-default | Yes |
/de/guides/configure | Fallback copy of docs/guides/configure.mdx | de | /guides/configure | Not a member | No |
/de/guides/configure is the fallback copy. Blume renders the English page there so German readers' links keep working, points its canonical at the English page, and leaves it out of the sitemap, site search, and every hreflang group. It doesn't add noindex: the canonical carries the signal, which is what Google recommends over noindex for duplicates within a site. English configure's group lists only English, because there's no German version to name yet.
The row in bold is the broken one: /de/guides/install is a real German translation whose canonical names the English page.
Read the generated head
Start the dev server with npm run dev, then in a second terminal print the language tags of three pages:
for path in /guides/install /de/guides/install /de/guides/configure; do
echo "== $path"
curl -s "http://localhost:4321$path" \
| grep -oE '<html[^>]*>|<link[^>]*(canonical|hreflang)[^>]*>'
done== /guides/install
<html dir="ltr" lang="en">
<link rel="canonical" href="https://docs.acme.example/guides/install">
<link href="https://docs.acme.example/de/guides/install" hreflang="de" rel="alternate">
<link href="https://docs.acme.example/guides/install" hreflang="en" rel="alternate">
<link href="https://docs.acme.example/guides/install" hreflang="x-default" rel="alternate">
== /de/guides/install
<html dir="ltr" lang="de">
<link rel="canonical" href="https://docs.acme.example/guides/install">
<link href="https://docs.acme.example/de/guides/install" hreflang="de" rel="alternate">
<link href="https://docs.acme.example/guides/install" hreflang="en" rel="alternate">
<link href="https://docs.acme.example/guides/install" hreflang="x-default" rel="alternate">
== /de/guides/configure
<html data-pagefind-ignore="all" dir="ltr" lang="de">
<link rel="canonical" href="https://docs.acme.example/guides/configure">
<link href="https://docs.acme.example/guides/configure" hreflang="en" rel="alternate">
<link href="https://docs.acme.example/guides/configure" hreflang="x-default" rel="alternate">The German install page contradicts itself. Its hreflang names it as the German member of the group, while its canonical asks search engines to index the English URL instead. A search engine that follows the canonical indexes the English URL in place of the German one, and the group's German entry points at a page that isn't indexed.
The fallback copy is consistent. It claims no German entry, its canonical and x-default both name the English page, and the extra attribute on <html> is one of the ways Blume keeps it out of site search. Its article also carries lang="en", so screen readers read the English text as English.
Run the audit
Stop the dev server, build, and run the checks that cover languages, canonicals, and the sitemap. --verbose prints each finding's message under its page:
npx blume build
npx blume audit --only i18n,indexability,sitemap --verboseThe report includes these findings:
| Check | Severity | Page | Message |
|---|---|---|---|
BLUME_AUDIT_HREFLANG_BAD_TARGET | error | /guides/install, /de/guides/install | hreflang="de" points at /de/guides/install, which canonicalizes elsewhere. |
BLUME_AUDIT_NON_CANONICAL_IN_SITEMAP | error | /de/guides/install | /de/guides/install is in the sitemap but canonicalizes to /guides/install. |
BLUME_AUDIT_CANONICAL_NOT_SELF | info | /de/guides/install | Page declares /guides/install as its canonical, so it will not be indexed itself. |
The errors make the command exit with code 1. Nothing is reported for /de/guides/configure: the audit knows it's a fallback copy and expects it to point elsewhere.
Each finding names the file to open. The sitemap and canonical findings point at line 5 of docs/de/guides/install.mdx, the canonical line. The hreflang finding on /guides/install names docs/guides/install.mdx, because that page carries the link, but the cause is the target named in the message.
Fix the German canonical
Delete the seo block from the German page, so it falls back to Blume's self-canonical:
---
title: Acme installieren
description: Erstelle einen API-Schlüssel und sende deine erste Nachricht mit der Acme Messages API.
---
Erstelle im Acme-Dashboard einen API-Schlüssel und sende dann eine Testnachricht an deine eigene Adresse. Danach kannst du [dein Konto konfigurieren](/guides/configure).Remove the same two lines from docs/guides/install.mdx too. Its self-canonical is redundant, and while it's there, the next blume translate run that touches the page copies it back into the German file.
Build and audit again:
npx blume build
npx blume audit --only i18n,indexability,sitemapThe three findings are gone. With no errors left in those categories, the command exits with code 0. The German page's head now names itself:
<html dir="ltr" lang="de">
<link rel="canonical" href="https://docs.acme.example/de/guides/install">
<link href="https://docs.acme.example/de/guides/install" hreflang="de" rel="alternate">
<link href="https://docs.acme.example/guides/install" hreflang="en" rel="alternate">
<link href="https://docs.acme.example/guides/install" hreflang="x-default" rel="alternate">In the matrix, the German install row's canonical now reads /de/guides/install. Every member of each group is canonical to itself and names the others, and the fallback copy stays outside.
Choose what untranslated pages do
Blume renders a fallback copy for every missing translation by default. The alternative is to serve a 404 instead:
i18n: {
defaultLocale: "en",
locales: [
{ code: "en", label: "English" },
{ code: "de", label: "Deutsch" },
],
fallbackLocale: null,
},Your hreflang groups are the same either way, since fallback copies were never members. The difference is for readers. With fallbacks, the German sidebar lists every page and German links stay in German navigation. With null, the German sidebar lists only translated pages, and a German page's link to an untranslated page goes to the English one. The language switcher still offers German on an untranslated English page, marked as not translated, and that link lands on the 404.
The default also means a translation ships without a URL change. Once docs/de/guides/configure.mdx exists, /de/guides/configure turns from a copy into a real translation: it becomes self-canonical, joins the group on both pages, and enters the sitemap.
Keep it checked in CI
The audit reads the built files and needs no network, so it runs right after your build step:
npx blume build
npx blume audit --only i18n,indexability,sitemapThe default gate fails on errors, which covers the hreflang and sitemap conflicts in this guide. Drop --only to run every check. For a complete workflow file, see the SEO audit guide.
Troubleshooting
The build has no canonical or hreflang tags
Blume needs your site's URL to write them. Without deployment.site, the audit reports BLUME_AUDIT_SITE_NOT_SET. With a platform adapter like vercel(), the URL is detected at deploy time, so a local build lacks it and the audit reports BLUME_AUDIT_SITE_INFERRED_AT_DEPLOY instead; audit a production-like build or the deployment. In blume dev, the URL falls back to http://localhost:4321, so dev shows tags that a build without a site URL won't have.
A translation isn't linked to its original
The German file's path doesn't match. A page at docs/de/anleitungen/installation.mdx publishes at /de/anleitungen/installation in a group of its own, so the audit reports BLUME_AUDIT_HREFLANG_XDEFAULT_MISSING there, and /de/guides/install stays a fallback copy. A frontmatter slug replaces the path, so give a translation the same slug as its original, or leave it off both. Move the file and redirect the old URL if it was published:
git mv docs/de/anleitungen/installation.mdx docs/de/guides/install.mdxredirects: [
{ from: "/de/anleitungen/installation", to: "/de/guides/install", status: 308 },
],The URL migration guide covers redirects in depth.
German pages declare lang="en"
The de folder isn't in i18n.locales, so Blume treats it as English content at /de/… and warns with BLUME_I18N_UNCONFIGURED_LOCALE. Add the locale to your config.
English pages also appear under /en/
The default locale lives at the content root, so a docs/en/ folder is ordinary content that publishes at /en/…, and at /de/en/… as a fallback copy. Blume warns with BLUME_I18N_DEFAULT_LOCALE_FOLDER. Move its contents up to docs/.
Search Console lists German URLs as alternate pages
For a fallback copy, "Alternate page with proper canonical tag" is the canonical working, and Google's help says there's nothing you need to do. If a real translation shows "Duplicate, Google chose different canonical than user," check that its main content is actually translated: Google treats a page whose header, footer, and navigation are translated but whose body isn't as a duplicate. The indexing guide covers the other statuses.
Next step
Audit your translated pages
Build your site, then run the language, canonical, and sitemap checks on what it produced.
npx blume audit --only i18n,indexability,sitemapA step here not working for you? Report a broken step.