Fixing Hreflang Errors Reported in Search Console
Hreflang tells search engines which pages are translations of each other, so a German reader gets the German page in results. It is also one of the easiest things to get subtly wrong. Every page in a set must list every other page, including itself; every URL must be the canonical one; and every language code must be valid. A static site generates all of this from templates, so one template bug produces thousands of errors at once — which is the good news, because one template fix removes them all.
This guide fixes the hreflang problems on a 9,000-page product site in five languages built with Hugo, after an SEO audit and Search Console's international targeting data showed German and French pages appearing in the wrong countries' results. It belongs to Internationalization for Static Sites; the basics of generating hreflang are in hreflang and Language Alternates on Static Sites.
Symptoms
- Search Console's URL Inspection showed German pages with "Google-selected canonical" set to the English page.
- An audit crawl with Screaming Frog reported 6,840 hreflang issues across 9,000 pages.
- In analytics, 31% of organic sessions on English pages came from Germany and Austria, where German pages existed.
Step 1: Crawl and Group the Errors
Crawl the built site rather than the live one, so fixes can be verified before deploying. A short script over the HTML output collects every page's link rel="alternate" hreflang set and checks the rules:
for (const [url, alts] of pages) {
for (const [lang, target] of Object.entries(alts)) {
if (!pages.has(target)) err(url, 'target not built', target);
else if (pages.get(target)[lang] === undefined) err(url, 'target lacks self-reference', target);
else if (!Object.values(pages.get(target)).includes(url)) err(url, 'no return link', target);
if (canonical.get(target) !== target) err(url, 'target not canonical', target);
if (!/^([a-z]{2,3})(-[A-Z]{2})?$|^x-default$/.test(lang)) err(url, 'invalid code', lang);
}
}
Grouped by error type:
Cause 1: Translations Linked by Title Instead of Key
The template found translations with .Translations, which in Hugo matches pages by their path (or by a translationKey). Writers had translated some pages under different file names — pricing.md became preise.md in German — without setting translationKey, so Hugo saw them as unrelated pages. The English page listed no German alternate, while a hand-written override on the German page pointed at the English one. One direction, no return.
The fix was to set translationKey on every page whose file name differs between languages:
---
title: Preise
translationKey: pricing
---
A script found the 214 affected page groups by matching pages across languages through an existing mapping in the translation management system's export. After adding the keys, .AllTranslations returned complete sets for all of them.
Cause 2: URLs That Were Not Canonical
The template built alternate URLs with .RelPermalink and prefixed the base URL by hand, producing https://example.com/de/preise — no trailing slash. The canonical tag, generated by a different partial using .Permalink, was https://example.com/de/preise/. The host redirected the first to the second, so every hreflang target was a redirect. Using .Permalink in both places made them identical:
{{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Language.LanguageCode }}" href="{{ .Permalink }}">
{{ end }}
{{ with .Sites.Default.GetPage .Path }}<link rel="alternate" hreflang="x-default" href="{{ .Permalink }}">{{ end }}
.AllTranslations includes the page itself, which gives the required self-reference.
Cause 3: Invalid Language Codes
The site config used languageCode = "en_US" and "de_DE" — underscores, which are the locale format of some operating systems but not valid in hreflang (BCP 47 uses hyphens). Search engines ignore invalid codes. Changing them to en-US, de-DE and so on fixed 980 errors. The site also reconsidered whether region codes were needed at all: the German content served Germany, Austria and Switzerland equally, so de without a region was more accurate than de-DE, which signals a preference for Germany.
Cause 4: Alternates to Pages That Were Never Built
410 errors pointed at pages that existed as files but were excluded from the build: drafts, and pages with build.render = "never" in some languages because their translation was incomplete. Hugo's .AllTranslations includes pages that will not render in some configurations. Filtering on whether the page is rendered fixed it:
{{ range where .AllTranslations ".Draft" false }}
{{ if .IsTranslated | or (eq . $) }}{{/* emit only rendered translations */}}{{ end }}
{{ end }}
In practice the simpler fix was to stop rendering incomplete translations at all, removing them from .AllTranslations entirely, and to show a "this page is not yet available in German" notice on the language switcher instead.
Step 2: Test Hreflang in CI
The crawl script from Step 1 runs in CI against the built output on every pull request and fails on any error. It adds 6 seconds for 9,000 pages. The same output is used to generate an hreflang section in the sitemap, so the HTML and sitemap cannot disagree.
Step 3: Watch the Effect
Search engines recrawl gradually, so the result takes weeks to show. The team tracked two numbers weekly: the share of organic sessions on each language's pages from that language's main countries, and the number of German and French pages where Search Console's inspection reported the English page as Google-selected canonical (sampled weekly on 50 URLs).
Language Switchers and Hreflang Should Agree
While fixing the tags, the team found the visible language switcher had its own logic: it linked to the target language's home page whenever it could not find a translation by URL pattern, which was most of the time for pages with localised slugs. Readers on the English pricing page clicking "Deutsch" landed on the German home page, not on "Preise". Rebuilding the switcher from the same .AllTranslations data as the hreflang tags fixed it, and made the two impossible to drift apart again. The switcher now shows a language as unavailable rather than silently sending readers somewhere else, and the CI crawl checks that every switcher link matches an hreflang alternate on the same page.
This matters beyond search. Readers who follow a search result to the wrong language often look for the switcher next; if it sends them to a home page, many leave. After the fix, exits from English pages within ten seconds by visitors from German-speaking countries fell from 22% to 14%.
Measured Impact
| Measure | Before | After | Tool |
|---|---|---|---|
| Hreflang errors on built site | 6,840 | 0 | CI crawl script |
| Audit crawl hreflang issues | 6,840 | 0 | Screaming Frog |
| Sampled DE/FR pages with English as Google canonical | 19 of 50 | 1 of 50 | URL Inspection |
| German-market sessions on German pages | 61% | 89% | Analytics |
| CI time added | — | 6 s | CI logs |
Pitfalls & Rollback
- Matching translations by file name. Set a translation key whenever names differ.
- Building alternate URLs by string concatenation. Use the same permalink function as the canonical tag.
- Underscore language codes. Hreflang needs BCP 47 codes with hyphens.
- Linking to unbuilt translations. Only list pages that exist in the output.
- Rollback: the changes are in one partial, the language config and front matter keys; reverting them restores the old tags, and the CI check would flag the errors again.
Conclusion
Hreflang errors on a static site come from a few template and content patterns, multiplied across every page: translations not linked by key, alternate URLs that differ from canonicals, invalid language codes and links to unbuilt pages. Fixing four causes cleared 6,840 errors, and eight weeks later German readers landed on German pages 89% of the time instead of 61%.
FAQ
What does no return tags mean in hreflang reports?
Page A declares page B as its alternate in another language, but page B does not declare page A back. Search engines ignore hreflang pairs that are not reciprocal. On static sites it usually happens when a translation exists only in one direction, when URLs differ by a trailing slash, or when one language's template generates alternates differently from another's.
Should hreflang point to the canonical URL?
Yes. Every URL in an hreflang set should be the canonical URL of its page, returning 200, with no redirect. Pointing at a URL that redirects, or at a page whose canonical tag names a different URL, makes the annotation ambiguous and it is typically ignored.
Do I need x-default?
It is optional but useful. x-default names the page to show when no language in the set matches the user, commonly a language picker or the English version. Without it, search engines choose on their own. Include it on every page in the set, pointing to the same URL.
Where should hreflang go on a static site, HTML or sitemap?
Either works, and both together is fine if they agree. HTML link tags are easiest to generate in templates and to test per page. Sitemap annotations keep HTML smaller on sites with many languages. Choose one as the source of truth and generate both from the same data if you use both.
Related
- Parent: Internationalization for Static Sites — URL structure, routing and translation workflows.
- hreflang and Language Alternates on Static Sites — generating hreflang correctly from the start.
- Multilingual Sites with Hugo — Hugo's language configuration.
- Canonical URLs and Duplicate Content on Static Sites — the canonical tags hreflang must match.
- Subdirectories vs Subdomains for Translated Static Sites — the URL structure these tags describe.
Filed underFixes