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:

Number of hreflang errors on the built site by error type Horizontal bars. No return link 3,120 errors. Target URL not canonical because of a missing trailing slash 2,210. Invalid language code 980. Target page not built 410. Missing self-reference 120. Hreflang errors by type No return link 3120 Target not canonical 2210 Invalid language code 980 Target not built 410 No self-reference 120
6,840 errors across 9,000 pages; four template bugs accounted for all of them.

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.

Language codes used in hreflang before and after the fix A matrix of five languages. English was en underscore US, invalid, and became en. German was de underscore DE, invalid, and became de, serving Germany, Austria and Switzerland. French was fr underscore FR, invalid, and became fr. Spanish was es underscore ES and became es. Brazilian Portuguese was pt underscore BR and became pt-BR because the content is specific to Brazil. Language codes before and after Language Before After Why English en_US en global audience German de_DE de DE, AT and CH French fr_FR fr FR, BE, CH, CA Spanish es_ES es Spain and Latin America Portuguese pt_BR pt-BR Brazil-specific pricing
Region codes were kept only where the content is genuinely regional.

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).

Share of organic sessions from each market that landed on the matching language Paired bars, before and eight weeks after the fix. Germany and Austria went from 61 to 89 percent of organic sessions landing on German pages. France went from 70 to 91 percent on French pages. Brazil went from 58 to 87 percent on Portuguese pages. Sessions landing on the right language Before 8 weeks after DE + AT → German pages 61% 89% FR → French pages 70% 91% BR → Portuguese pages 58% 87%
Analytics, organic search sessions by country, four-week windows before the fix and eight weeks after.

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

MeasureBeforeAfterTool
Hreflang errors on built site6,8400CI crawl script
Audit crawl hreflang issues6,8400Screaming Frog
Sampled DE/FR pages with English as Google canonical19 of 501 of 50URL Inspection
German-market sessions on German pages61%89%Analytics
CI time added—6 sCI 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.

Filed underFixes