hreflang: reciprocity, x-default, and the mistakes that void the whole set
hreflang is the only check in this catalogue where being 95% correct is worth roughly the same as doing nothing. It is evaluated as a set, and a set with one bad member is discarded rather than partially honoured. That property explains almost every hreflang problem anyone has.
The short answer
hreflang tells search engines which language and region version of a page to serve, and it fails as a set rather than per page. Every URL in a language group must point at every other one including itself, and a single missing return link can void the whole group. Use language codes, not country codes.
Why hreflang fails all at once
An hreflang annotation is a claim about a group: these URLs are the same content for different audiences. A search engine can only act on that claim if the group agrees with itself, so the validation is mutual. Every page in the set must list every other page, including itself, and every listed URL must point back.
When one page in a twelve-language set is missing its return links, the engine cannot tell whether it belongs to the group or was named by mistake. The safe response, and the one it takes, is to ignore the annotations rather than guess. So eleven correct pages get no benefit from work that was eleven-twelfths finished.
There is no partial credit, and no error message anywhere telling you that you did not get it.
The return-link rule, in markup
Each page carries the full set. The self-reference is not optional and is the single most commonly omitted line, usually because it looks redundant when you are editing a page that obviously is itself.
<!-- On https://example.com/en-gb/widgets -->
<link rel="alternate" hreflang="en-gb"
href="https://example.com/en-gb/widgets">
<link rel="alternate" hreflang="en-us"
href="https://example.com/en-us/widgets">
<link rel="alternate" hreflang="de-de"
href="https://example.com/de-de/widgets">
<link rel="alternate" hreflang="x-default"
href="https://example.com/widgets">The German page carries that same block, unchanged, including the line pointing at itself and the lines pointing back at the English versions. Identical on every member of the set is the target state, which is why this is generated rather than hand-written past about three languages.
const LOCALES = ['en-gb', 'en-us', 'de-de'] as const
export async function generateMetadata() {
// Generated from one list, so every page in the set
// emits the identical block and reciprocity cannot
// drift when a locale is added.
return {
alternates: {
languages: {
...Object.fromEntries(
LOCALES.map((l) => [l, `/${l}/widgets`]),
),
'x-default': '/widgets',
},
},
}
}What is evaluated across the whole crawl
Three things, evaluated across the whole crawl rather than per page: whether every annotation has a matching return link, whether each declared code is a valid language and region, and whether the URLs named actually resolve with a 200 rather than redirecting or erroring.
It is a site-level check by necessity. Reciprocity is a property of a set, so it cannot be computed from one page in isolation, which is also the limit the widget below reports honestly rather than working around.
Check one page for its language declaration
Paste a URL. A single-page audit can report the page's own lang attribute, which is the cheapest language signal and frequently missing. Reciprocity needs the full crawl, and the widget says so.
No signup required. Each free search audits one page, paste any URL to see it in action.
en-UK is not a language code
The single most common invalid value, and it fails silently. The language part is ISO 639-1 and the optional region part is ISO 3166-1 alpha-2, which means the United Kingdom is GB. en-UK looks obviously right to an English speaker and is simply not a code, so the annotation containing it is discarded.
en-UK invalid the country code is GB, not UK
en-EU invalid the EU is not a country
zh-CN valid but consider zh-Hans (script, not region)
pt-BR valid Brazilian Portuguese
de valid language only, any region
x-default the fallback for unmatched visitorsTwo further traps. The order is language then region, never the reverse, so gb-enis invalid. And region alone is not a thing: there is no way to say "visitors in Germany" without also naming a language, which is what x-default exists to work around.
x-default, and when you genuinely need it
x-defaultnames the page to serve when no other entry matches the visitor. It is not "the English one" and it is not a default language: it is the fallback for everyone your set does not cover.
You need it when you have a language selector, a global landing page, or any set that does not cover most of the world. You do not need it when your set genuinely is exhaustive for your audience, and adding it pointing at an arbitrary language is worse than omitting it, because it commits you to serving that page to unmatched visitors.
The IP-redirect trap is the one that voids sets that otherwise validate.
If /en-us/widgets detects a German visitor and redirects them to /de-de/widgets, then the URL your annotation promised does not return the content it claimed. A crawler following the annotation from a German IP sees a redirect instead of a page and drops the entry. Suggest a locale, never force one.
Three delivery methods, one you should avoid
The annotations can live in three places, and the choice has real consequences for maintenance.
HTML head. The default and the easiest to debug, since view-source shows you the whole set. Cost is page weight: a fifty-language set is fifty elements on every page.
XML sitemap. The right answer at scale. Annotations live in one file, so adding a locale is one edit rather than a template change reaching every page, and the page weight is zero.
<url>
<loc>https://example.com/en-gb/widgets</loc>
<xhtml:link rel="alternate" hreflang="en-gb"
href="https://example.com/en-gb/widgets"/>
<xhtml:link rel="alternate" hreflang="de-de"
href="https://example.com/de-de/widgets"/>
</url>
<!-- Then a matching <url> block for the German page,
carrying the same two annotations. Reciprocity is
still required; the sitemap only moves where the
claims are stated. -->HTTP headers. Available, and the one to avoid unless you are annotating non-HTML files such as PDFs. It is invisible in view-source, which makes debugging painful, and it puts SEO configuration in a layer your content team cannot see or edit.
Whichever you pick, pick one. Annotations split across the head and the sitemap are a reliable way to end up with two sets that disagree, and disagreement is the condition that voids both.
Why a voided language set is only Important
Because a broken hreflang set does not remove anything. Every localised page still ranks in its own right, still gets indexed and still serves visitors. What is lost is the engine knowing they are the same content, so the wrong locale surfaces for some searchers and the versions may compete with each other.
The rating is also a judgement about scope: this check only matters at all if you serve multiple locales. For a single-market site there is nothing here to get wrong, which is different from a noindex or a missing canonical, where every site is exposed. See the severity scale for how that weighting works.
Does this affect AI search?
Barely, and the reason is structural. hreflang exists to help a ranking system choose between near-identical documents for a given searcher. An assistant answering a question is not running that selection: it has retrieved some content and is generating a reply, and it is perfectly capable of answering in the user's language from a source in another one.
The part that does carry over is the humbler one on the same page: the lang attribute. A declared document language is a direct, unambiguous statement of what a passage is written in, and it is useful to anything deciding whether a chunk of text matches a query's language. It costs one attribute and it is missing from a surprising number of pages.
So the honest ordering: implement hreflang for search, because that is what it is for, and make sure every page declares its language regardless of whether you have multiple locales at all. The second one is not an hreflang task and is worth doing on single-language sites that will never need the first.
Confirming reciprocity
Do not spot-check pages. The failure mode is a property of the whole set, so checking one page tells you almost nothing: it will look correct right up until you discover the one locale that never listed the others.
What you want is a crawl that fetches every URL in the set and verifies the return links in both directions, plus the codes and the status of every target. Then look at the international report as a matrix rather than a list, because a missing return link shows up as a gap in a grid and is invisible in a page-by-page view.
Re-check after adding any locale. Adding a language means editing the annotation block on every existing page in the set, which is the step most likely to be missed, and missing it breaks the locales that were already working.
Questions this check raises
- Why is my hreflang being ignored?
- Most often because the return links are incomplete. hreflang is reciprocal by design: if page A names page B but B does not name A, the engine cannot verify the relationship and discards the annotation. One missing return link in a set of twelve can invalidate the set.
- Is en-UK a valid hreflang value?
- No, and it is the most common error in the whole feature. The language subtag is ISO 639-1 and the optional region subtag is ISO 3166-1 alpha-2, which makes the United Kingdom GB. en-UK is silently invalid: nothing errors, and the annotation is simply not applied.
- Should hreflang go in the head, the sitemap, or the HTTP header?
- The head or the sitemap. The head is the easiest to verify because it is visible in the page source; the sitemap scales better past a few languages and keeps the head small. The header form exists for non-HTML files. Do not use two methods at once, because keeping them in agreement becomes the maintenance burden.