Your German pricing page used to live at /de/pricing/. Someone pointed out that /de/preise/ reads like a German page rather than a translated English one, and they were right, so you changed it. The redirect went in and the page kept ranking. Meanwhile, in the <head> of your English pricing page, a <link rel="alternate" hreflang="de"> is still pointing at /de/pricing/, a URL that no longer exists. No error was raised anywhere in that chain, and the tag itself is still perfectly valid HTML.
Hreflang tags generated from the URL path cannot survive localized slugs, because the path stopped being a reliable description of where the other language version lives. The relationship between two language versions is a fact about your content, which means it has to be stored with your content and the tags generated from that. You can work out which side of that line your site is on in about five minutes, from the page source, without asking anyone for access to anything.
Why does localizing your URLs break hreflang?
Most hreflang implementations build the alternate URL by transforming the current path: take /pricing/, prefix /de/, emit /de/pricing/. That works exactly as long as your slugs mirror each other, and it is the normal way to build it, because for a long time it was also the correct way. The moment a German slug becomes preise, the generator keeps emitting /de/pricing/.
| What your slugs look like | What a path-derived generator emits | What actually exists | Result |
|---|---|---|---|
/pricing/ and /de/pricing/ |
/de/pricing/ |
/de/pricing/ |
Correct, by coincidence |
/pricing/ and /de/preise/ |
/de/pricing/ |
/de/preise/ |
Points at a 404 or a redirect |
/plans/ and /de/preise/ |
/de/plans/ |
/de/preise/ |
Broken in both directions |
| English page only | /de/pricing/ |
Nothing | Points at a 404 |
A redirect makes this harder to spot. The URL still resolves, so a spot check looks fine and the person doing the spot check moves on. Google’s rule is that if two pages do not both point to each other, the tags are ignored, and a tag that resolves through a redirect is not pointing at the page that points back at it.
How do you check your own hreflang in five minutes?
Open one language version, view source, and search the <head> for hreflang. Run three checks against what you find: does the set include the page itself and every other version, does the canonical point at a URL in the same language, and does every URL in the set actually load. Then repeat on a second language version.
Pick a page whose two slugs are not the same word. A mirrored pair like /pricing/ and /de/pricing/ passes all three checks by coincidence, and passing on that page tells you nothing about the rest of the site.
| # | Check | What you do | Pass condition | What a fail means | What to ask your developer for |
|---|---|---|---|---|---|
| 1 | The return tag | List every hreflang URL on the German page. Open the English page and list them again |
Both pages carry the identical set, and each set includes the page you are looking at | A one-way annotation. Google ignores it, so you are getting none of the benefit you think you have | The alternate set built once and rendered identically on every version of the page |
| 2 | The canonical | Find rel="canonical" on the German page and read the URL |
It points at a URL in the same language, normally the page itself | The German page is telling Google the English page is the real one. This is the most expensive of the three | A self-referencing canonical on every language version |
| 3 | Does the target exist | Paste each URL from the hreflang set into the address bar and press enter. Once the page has loaded, compare the URL now sitting in the address bar against the one you pasted | Every URL loads a page, and the address bar still shows exactly what you pasted | A URL that does not load is broken outright. A URL that changed in the address bar went through a redirect, which is a fail even though a page loaded. Either way your tags are derived from a path guess, and you have found the bug this piece is about | Alternate URLs looked up from a stored field on the content entry, never built from the request path |
If the <head> has no hreflang in it, check the HTTP response headers and the XML sitemap before concluding there is none. Google treats those three delivery methods as equivalent and notes there is no benefit in Search to using all three at once, so most sites pick one and a fair number pick the sitemap.
Which language codes are actually valid?
The value is one code, or two separated by a dash. The first code is the language in ISO 639-1 format, the optional second is the region in ISO 3166-1 Alpha 2. Google reads the first code as a language and will not derive a language from a country, which is where most of the invalid values come from.
| Value | Valid | Why |
|---|---|---|
de |
Yes | German content, region independent |
de-AT |
Yes | ISO 639-1 language plus ISO 3166-1 Alpha 2 region |
be for Belgium |
No | Google reads the first code as a language, and be is the Belarusian language code. Use de-be, nl-be and fr-be |
en-UK |
No | UK is a reserved code with no assigned region behind it, so Google ignores that part of the annotation. The region code is GB |
x-default |
Yes | The recommended fallback for users whose language settings match none of your versions |
Can Search Console tell you any of this?
No. The International Targeting report, which was where hreflang errors used to surface, has been deprecated and removed from Search Console. Google’s help page for it states that it continues to support and use hreflang tags on your pages, while the ability to target search results to specific countries through Search Console is no longer supported.
Plenty of guides written before that still send readers to a report that is gone. Any crawler that fetches both language versions and compares the annotation sets does the same job. So does opening two pages and reading them, which is what the three checks above are.
What happens when a page canonicalizes to another language?
It takes the translated page out of search. A canonical is a statement that some other URL is the representative version of this content, so a German page canonicalizing to the English one is asking Google to consolidate the pair and show the English URL. Google’s guidance for sites using hreflang is to specify a canonical page in the same language.
The exact line sits in Google’s canonicalization best practices: “If you’re using hreflang elements, make sure to specify a canonical page in the same language, or the best possible substitute language if a canonical page doesn’t exist for the same language.” Two bullets above it, the same list says to include a rel="canonical" link on the canonical page itself, which is the self-referencing canonical that check 2 is looking for.
Where that sentence lives explains a lot about how the mistake keeps happening. Google’s page about telling it about localized versions of your page covers the return-link rule, the code formats, x-default, the three delivery methods and a troubleshooting section, and the body of it does not mention rel="canonical" once. We read both documents end to end on 2026-08-07 to confirm that. So the page a developer opens while implementing hreflang is the one that leaves the canonical rule out, and the page that carries the canonical rule is the one nobody opens on hreflang day.
Two more details from Google’s canonicalization page are worth carrying into the conversation with whoever owns your templates. A rel="canonical" annotation that carries an hreflang attribute is not used for canonicalization at all, so the canonical and the alternate have to be separate link elements doing separate jobs. And Google says that for canonicalization purposes it prefers URLs that are part of hreflang clusters, which means a page that has dropped out of the set is also a page more likely to lose its own canonical.
Why this cannot be a manual job
Every version of a page has to list every version including itself, so the annotation count is the square of your language count times your page count. Six languages across 400 content items is 2,400 URLs, each carrying a six-entry set, so 14,400 link elements that all have to agree with each other. Add a seventh language and every existing page changes.
That arithmetic is why the tags get generated. The interesting question is what they get generated from, and a template that concatenates the current path with a language prefix is generating them from a guess about your URL structure. It is a guess that stays right until somebody does the thing every guide about international SEO recommends.
Store the relationship, then generate the tag
Add a field to your content model that names the counterpart entry, and build every hreflang URL by looking up that entry’s real slug. The relationship stops being something a template infers from the string in the address bar and becomes something an editor sets and the system reads.
Post (English)
slug: "pricing"
locale: "en"
counterpart: reference to the German entry
Post (German)
slug: "preise"
locale: "de"
counterpart: reference to the English entry
That is one field, and what it holds decides whether any of this works. A reference to the entry survives a slug change, because the URL is resolved at build time from whatever slug that entry currently carries. Rename preise to kosten and the English page’s alternate URL follows on the next build. Store a copy of the counterpart’s slug as a string and the problem has only moved: the other entry still holds preise, so the tag now names a URL that stopped existing, and you have relocated the silent failure from the template into a field. A reference also gives the CMS something real to check, because an entry whose counterpart is missing is a content error you can surface to an editor at edit time, which is not a thing a <head> template can do.
We store the relationship this way on our own bilingual site, and it is worth being exact about how far ours actually goes. translationSlug is a field on the blog content schema next to locale, and the post layout derives the alternate URL from it in about seven lines: a German post with the slug preise and a translationSlug of pricing resolves to /blog/pricing/, whatever the two slugs happen to look like. The canonical is always the page’s own URL, emitted alongside en, de-AT and x-default. So check 2 passes by construction, and nobody has to remember to run it.
Check 1 is a different story on our site. The field is optional in the schema, and it is unilateral: it stores the counterpart’s slug as a string, and the build checks that the value is a string and stops there. Nothing verifies that the counterpart entry exists, or that the counterpart names this entry back. All 42 posts carry the field today, which is editorial discipline rather than construction, and a naming convention gets you the same behaviour right up until somebody forgets it. An optional field is a convention with a type check on it.
Publish a post without the field and the layout has no alternate URL to hand over, so it falls back to mirroring the path and emits /de/blog/ plus the English slug: a guessed URL, silently, on the site being offered here as the fix. Closing that is ask-for point 4 below, and it is the part we have not shipped.
This is the same argument that a strict data model makes editors faster makes from the editing side, arriving at a different consequence. A typed field is something the system can rely on, and it is one of the concrete reasons to hold content as structured data in a headless setup, where a relationship between two entries is a thing you can actually store.
What to ask for
If one of the three checks failed, the request to your development team is short and specific:
- Generate the alternate set from a stored relationship field on the content entry, not from the request path.
- Render the identical set on every language version of a page, self-reference included.
- Emit a self-referencing canonical on every language version, and never point it at a different language while a version exists in the same language.
- Fail the build, or flag the entry in the CMS, when a content item claims a counterpart that does not exist.
Point 4 is the one that gets left off, and it is what turns this from a fix into something that stays fixed. A generated tag can only be as correct as the field it reads, so the field needs a guard.
Worth spending the five minutes this week rather than after the next market launch, because these tags do not get louder about being wrong. If the architecture decisions above this one are still open, launching in a new market without rebuilding everything covers them, and the CMS translation workflow covers how the content gets into the second language in the first place. If you just failed check 2 on your own site and want somebody to work through the rest of the setup with you, that is what a headless audit is for.