hreflang is the only major SEO signal where a partial implementation is routinely worse than none at all. A missing tag costs you an optimisation. A malformed cluster can cost you the pages.

That asymmetry is why we treat it as a build-time concern rather than a checklist item. Below are the four failure modes we find most often, in descending order of how much damage they do.

1. Cross-locale canonicalisation

The single most destructive one, and the easiest to introduce by accident.

A Chinese page carries <link rel="canonical" href="https://example.com/pricing/"> pointing at the English original. The author’s intent was “these are the same page”. The signal’s meaning is “this URL is a duplicate of that one; index that one instead.”

Google obliges. The Chinese page leaves the index. Not deranked — gone.

<!-- WRONG: the zh page points its canonical at the en page -->
<!-- at https://example.com/zh/pricing/ -->
<link rel="canonical" href="https://example.com/pricing/" />

<!-- RIGHT: every page is its own canonical; hreflang expresses the pairing -->
<link rel="canonical" href="https://example.com/zh/pricing/" />
<link rel="alternate" hreflang="en" href="https://example.com/pricing/" />
<link rel="alternate" hreflang="zh-Hans" href="https://example.com/zh/pricing/" />
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing/" />

Canonical answers “which URL should represent this content”. hreflang answers “which version should this user get”. They are different questions and the first one is destructive when answered wrongly.

2. A cluster pointing at a 404

hreflang annotations are validated as a set. If a page in the cluster returns a 404, a 301, or a noindex, the confirmation link between the pages breaks — and in practice Google frequently discards the annotations for the entire cluster, not just the broken edge.

This is why the failure is so common on content sites specifically: someone adds a new English article, the template emits alternates for all configured locales, and the Chinese translation does not exist yet. Every article on the site now ships an hreflang pointing at a URL that has never existed.

The architectural fix is to derive the alternates from the content, not from the locale table. A page emits an alternate for a locale if and only if a published translation exists for it. That is a query, not a configuration constant.

3. Non-reflexive clusters

Every page in a cluster must list every page in the cluster, including itself. A page that omits its own self-referential hreflang is routinely ignored.

This one is almost always a templating bug: someone writes otherLocales(current) and maps over it, producing a matrix that is missing its diagonal. It looks tidier. It does not work.

// WRONG: excludes the current locale, so the cluster is never confirmed
LOCALES.filter((l) => l !== current).map(toAlternate)

// RIGHT: reflexive — every page lists every locale, itself included
LOCALES.map(toAlternate)

4. x-default pointing at a redirector

x-default is the fallback for a user whose language and region give you no useful signal. It should point at a real, indexable page — normally your primary locale’s version of that same page.

Two common wrong answers. Pointing it at a language-selection splash page, which sends your least-informed visitors to a fork in the road instead of to content. And pointing it at a geo-redirecting root URL, which means the crawler canonicalises whatever it happens to be served that day.

How to verify a fix

Do not verify hreflang by reading the template. Verify it against the built output, because the template is exactly where the bug is.

  1. Reflexivity. For every page, assert its own URL appears among its own alternates.
  2. Absoluteness. Relative hreflang hrefs are not honoured. Assert every href starts with https://.
  3. Reachability. Assert every hreflang target resolves to a real file in the build output. This is the check that catches failure mode 2, and it is a twenty-line script.
  4. Reciprocity. Assert that if A lists B, B lists A.
  5. One x-default. Exactly one, and it resolves.

We run all five over dist/ on every build. It is not sophisticated, and it has caught more real defects than any other check we have.