🍱 Lunchbox Hands

seo

hreflang Mistakes That Silently Break: en-UK, Self-Links, x-default

Why en-UK does nothing, why every page must list itself, what x-default is for, and which hreflang errors a validator can catch versus the ones only a crawl finds.

hreflang has no error console. Put a wrong code or a one-way link in your <head> and nothing breaks: the page loads, Search Console may or may not complain, and Google quietly drops the annotation. Most broken hreflang setups come down to a short list of mistakes: a region code that isn’t a country code (en-UK), a page that doesn’t list itself, a link the other page never returns, a relative URL, or a misunderstood x-default. Every one of them fails silently. This post goes through each mistake against the primary sources: Google’s localized versions guide, RFC 5646 (BCP 47 language tags), the IANA language subtag registry and RFC 8288 (the HTTP Link header). It also says plainly which mistakes our hreflang tag generator catches and which ones it can’t.

The shape of a valid tag

Google’s rule is short: the value is “the language code (in ISO 639-1 format) followed by an optional second code that represents the region code (in ISO 3166-1 Alpha 2 format).” You can also add an ISO 15924 script between them, as in zh-Hant. Language first, country second, and never a country alone: “You can’t specify the country code by itself.”

TagMeaningValid for Google?
deGerman, any countryYes
en-GBEnglish, United KingdomYes
zh-Hant-TWChinese, Traditional script, TaiwanYes
en-UKEnglish, and a region Google ignoresRegion part has no effect
gbNothing useful (gb isn’t a language)No
engEnglish as a three-letter ISO 639-2 codeNo (Google asks for ISO 639-1)
es-419Spanish, Latin America (UN M.49 region)Valid BCP 47, but Google says it isn’t supported

Case doesn’t matter. Google says “the hreflang value is case-insensitive,” and RFC 5646 §2.1.1 says the capitalization conventions “MUST NOT be taken to carry meaning.” EN-gb and en-GB are the same tag. The conventional spelling is still worth using (lowercase language, Titlecase script, UPPERCASE region), because it makes mistakes easier to spot. Our generator normalizes to that form, so EN-gb comes out as en-GB and zh-hant-tw as zh-Hant-TW.

Mistake 1: en-UK (and EU, UN, EL)

This is the most common one because it looks right. The United Kingdom’s ISO 3166-1 code is GB. UK is only “exceptionally reserved” in ISO 3166-1, and RFC 5646 §2.2.4 went out of its way to leave it out of the language-tag registry. Exceptionally reserved codes were added, “with the exception of ‘UK’, which is an exact synonym for the assigned code ‘GB’.” You can check this in the current IANA registry: it has Subtag: GB / Description: United Kingdom and no UK entry.

Google’s troubleshooting section names this mistake. If you use codes that are reserved for something else, “Google Search ignores that part of the annotation (for example, using EU, UN, or UK in hreflang annotations doesn’t have an effect on Google Search).”

EU is an interesting case. The IANA registry does include it (Subtag: EU / Description: European Union), so en-EU is a well-formed BCP 47 tag. Google still ignores it, because hreflang targets countries and the EU isn’t one. If you want “English for Europe”, use en or one tag per country.

Two related traps:

  • uk on its own is Ukrainian. It’s a perfectly valid ISO 639-1 language code. A site that tags its British page hreflang="uk" has told Google that page is in Ukrainian.
  • eu on its own is Basque. The registry lists Subtag: eu / Description: Basque. So hreflang="eu" for “Europe” passes every syntax check and means something completely different.

Here’s what our validator reports for these, taken from running validateTag() in src/lib/hreflang.ts:

InputOur result
en-UK / en-ukWarning: “UK is not an ISO 3166-1 country code — the United Kingdom is GB — Google will ignore “UK"", with a one-click Use en-GB fix
en-EUError: “EU is not a country; hreflang targets individual countries (or use the language alone)“
UK (typed uppercase, alone)Warning: ""uk” means Ukrainian, not the United Kingdom”, fix en-GB
gb, jpError: “looks like a country code”, fixes en-GB, ja-JP
en_USWarning about underscores, normalized to en-US
engError: Google only accepts two-letter ISO 639-1 codes
iwError: deprecated, use he
es-419Warning: valid BCP 47, but Google lists codes like this as unsupported and will likely ignore it
eu, lowercase ukNo warning. Both are real languages, so we can’t know you meant something else

One honest caveat about our tool: en-UK is a warning, not an error. Rows with errors are left out of the output, but warning rows stay in. If you don’t click the fix, the generated markup will contain hreflang="en-UK". Click the fix.

Mistake 2: the page doesn’t list itself

Google puts this in bold: “Each language version must list itself as well as all other language versions.” A lot of people write the tags from each page’s point of view (“here are my other languages”) and leave out the self-reference.

The simplest rule is that every version carries the identical block. That’s why our HTML output repeats the whole set once per page instead of generating a tailored list for each one. Here’s a real run for a US English, UK English and German pricing page plus an x-default, where we typed the first tag as lowercase en-us:

<!-- In the <head> of: en-US — https://example.com/en-us/pricing -->
<link rel="alternate" hreflang="en-US" href="https://example.com/en-us/pricing" />
<link rel="alternate" hreflang="en-GB" href="https://example.com/en-gb/pricing" />
<link rel="alternate" hreflang="de" href="https://example.com/de/preise" />
<link rel="alternate" hreflang="x-default" href="https://example.com/pricing" />

<!-- In the <head> of: en-GB — https://example.com/en-gb/pricing -->
<link rel="alternate" hreflang="en-US" href="https://example.com/en-us/pricing" />
... same four lines ...

The four blocks, one each for en-US, en-GB, de and the x-default page, are byte-for-byte identical apart from the comment. For the same input the validator also printed an info-level tip: “consider a language-only fallback for en so speakers in other countries get a match.” That matches Google’s advice to provide “a catchall URL for geographically unspecified users of that language”. An Australian reader gets neither en-US nor en-GB, so adding a plain en gives them something to match.

This one does the most damage, because it happens on a site that was correct last month. Google: “If two pages don’t both point to each other, the tags will be ignored.” The reason is anti-hijacking: otherwise “someone on another site can’t arbitrarily create a tag naming itself as an alternative version of one of your pages.” Their common-mistakes list puts it more briefly: “If page X links to page Y, page Y must link back to page X.”

The usual cause is adding a language. Someone ships /fr/ with a full hreflang block, but nobody redeploys the English and German templates. Now fr points at en, en doesn’t point back, and Google ignores that pair. The pairs you didn’t touch still work, so the cluster degrades gradually and nobody notices.

This is the mistake our tool can’t check. It validates the set you paste in and writes a consistent block for every page, but it doesn’t fetch your site. It can’t tell whether the de page actually has the block you generated. The tool page says so directly. To catch missing return links you need a crawler that visits each URL and compares the sets. Our part is making the right fix easy: when a version is added or removed, regenerate the whole set and redeploy it to every page.

Mistake 4: relative URLs and fragments

Google: “Alternate URLs must be fully-qualified, including the transport method (http/https), so: https://example.com/foo, not //example.com/foo or /foo.” Relative URLs in <link rel="alternate"> work fine for browsers, which is why they get into templates unnoticed.

Our validateUrl() treats both forms as errors:

/de/preise        → Relative URL — hreflang URLs must be absolute (start with https://)
//example.com/x   → Relative URL — hreflang URLs must be absolute (add the scheme (https:))
https://example.com/#fr → warning: Google ignores #fragments — each language version needs its own URL
http://example.com/a    → info: Uses http:// — make sure this matches your canonical URL

Two more checks run across the whole set. A duplicate tag (en-us and en-US pointing at different URLs) is an error, and only the first row is kept. The same URL under two different language tags gets a warning. That setup is fine only if one page really serves both audiences.

Mistake 5: using x-default for the wrong page

Google defines it this way: “The reserved x-default value is used when no other language/region matches the user’s browser setting.” It also says what it’s for: “While you can use the x-default value for any page, it was designed for language selector pages and so it will work best with those.”

So x-default is a fallback, not “the default language of the site.” The common misuses:

  • Leaving it out of some pages. It’s part of the set, so it follows the same rule as every other tag: every page lists it.
  • Pointing it at a redirecting URL. If / geo-redirects visitors, you’re sending “no match” users to a page that decides for them. A plain language picker, or your most general version, fits Google’s description better.
  • Using it more than once. A set gets one x-default. Our validator treats a second one as a duplicate tag.

Pointing x-default at the same URL as your en page is fine, and our duplicate-URL check deliberately skips x-default for that reason. If your set has no x-default, the tool shows a warning. It won’t block you, because Google doesn’t require one.

Mistake 6: three methods, conflicting sets

hreflang can live in HTML <link> elements, in the XML sitemap, or in an HTTP Link header, and Google says “the three methods are equivalent.” Using more than one isn’t forbidden, but Google adds that “there’s no benefit in Search (in fact, it maybe be much harder to manage three implementations instead of just picking one).” The practical danger is drift. Your CMS template gets updated with the new fr page, the sitemap doesn’t, and now two sources disagree about the cluster. Choose one method per page type.

Our tool generates all three from the same validated set. Here’s the sitemap form, with the xhtml namespace Google asks for (xmlns:xhtml="http://www.w3.org/1999/xhtml") and one <url> per page, each carrying the full set:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
  <url>
    <loc>https://example.com/en-us/pricing</loc>
    <xhtml:link rel="alternate" hreflang="en-US" href="https://example.com/en-us/pricing" />
    <xhtml:link rel="alternate" hreflang="en-GB" href="https://example.com/en-gb/pricing" />
    <xhtml:link rel="alternate" hreflang="de" href="https://example.com/de/preise" />
    <xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/pricing" />
  </url>
  <!-- ...three more <url> entries with the same four xhtml:link lines -->
</urlset>

And here’s the HTTP header form. A PDF has no <head>, so this is the only way to annotate one:

Link: <https://example.com/en-us/pricing>; rel="alternate"; hreflang="en-US",
      <https://example.com/en-gb/pricing>; rel="alternate"; hreflang="en-GB",
      <https://example.com/de/preise>; rel="alternate"; hreflang="de",
      <https://example.com/pricing>; rel="alternate"; hreflang="x-default"

That follows the RFC 8288 grammar (link-value = "<" URI-Reference ">" *( OWS ";" OWS link-param ), with comma-separated values) and Google’s example format. Every version of the file should send the same header. RFC 8288 also sets expectations: the hreflang parameter “is only a hint.” It doesn’t redirect anyone, so keep a visible language switcher. Google also notes that it “doesn’t use hreflang or the HTML lang attribute to detect the language of a page.” hreflang connects versions to each other. It doesn’t tell Google what language a page is in.

What a validator can and can’t tell you

MistakeOur generator
en-UK, en_US, jp, eng, iw, region-only codesCaught, with a suggested fix
en-EU, en-UNCaught (error)
Relative or protocol-relative URLs, #fragmentsCaught
Duplicate tags, same URL for two languagesCaught
Missing self-referencePrevented: the output always includes every page
Missing x-defaultWarned
eu meaning Europe, uk meaning BritainNot caught: both are valid language codes
Missing return links on the live siteNot caught: it never fetches your pages
Alternates that redirect, 404, or aren’t canonicalNot caught, for the same reason

Everything in the top half runs in your browser. Nothing you paste into the generator is uploaded.

A practical workflow

  1. List every version once, as tag,url lines, in the hreflang tag generator’s bulk-paste mode. Fix every warning. Don’t leave any en-UK in there.
  2. Pick one delivery method. Put HTML blocks in your templates, or merge the xhtml:link entries into the sitemap you build with our sitemap generator. (It writes plain <url> entries, so the alternates are a paste-in.)
  3. For PDFs, set the Link header at your server or CDN. Then fetch the file with the HTTP header analyzer, which requests the URL through our server and lists every response header, and confirm the header actually comes back.
  4. Check that each alternate URL is the canonical, 200-status version. If one redirects, 301 vs 302 redirects covers which status you actually want. Titles and descriptions for each locale go in with the meta tag generator, and the rest of the head checklist is in our on-page SEO checklist.
  5. Whenever a language is added or removed, regenerate the full set and redeploy it to every version. Then, if you use it, ping the changed URLs as described in IndexNow vs sitemaps.