Hreflang Warning: Quick Guide to Mismatched Return Tags and Language Codes

What hreflang warnings mean, why mismatched clusters happen, and the minimal fixes that resolve return-tag and language-code errors. For the Search Console International Targeting workflow, see the linked page.

hreflang tells Google “this page has an equivalent version in another language/region — show that version to users searching in that language.” The rules are strict enough that hand-written hreflang is almost always broken: every locale must reference every other locale (return tags), language codes must be ISO 639-1, region codes must be ISO 3166-1, plus an x-default — and one missing or wrong entry invalidates the whole cluster.

Below are the most common hreflang warnings from Search Console’s “International Targeting” / “Pages” reports, and the shortest path to fix each.

Common causes

1. Missing return tag (most common)

Example:

<!-- on /zh/article -->
<link rel="alternate" hreflang="en" href="https://yourdomain.com/en/article/" />
<link rel="alternate" hreflang="zh" href="https://yourdomain.com/zh/article/" />

<!-- on /en/article (broken: forgot the zh return tag) -->
<link rel="alternate" hreflang="en" href="https://yourdomain.com/en/article/" />
<!-- missing: <link rel="alternate" hreflang="zh" href="...zh..."> -->

If the English page doesn’t reciprocate to Chinese, the entire pair breaks. Search Console reports “No return tags.”

How to confirm: Search Console → International Targeting → look for “Errors: No return tags.” Or use the hreflang.org checker — paste both URLs and let it verify reciprocity.

2. Wrong language / region codes

WrongRightWhy
zhzh or zh-Hans / zh-Hantzh alone works, but Simplified and Traditional collide if both use it
cnzh-CNcn is a region, not a language
twzh-TWSame
zh-hkzh-HKRegion code must be uppercase (Google tolerates lowercase but other tools don’t)
en-usen-USSame
en-uken-GBUK isn’t an ISO country code; GB is
zh-CN-Hanszh-Hans-CNScript before region
jpjaJapanese is ja; jp is a country code

How to confirm: Grab all <link rel="alternate" hreflang="..."> tags from one page and check each value against the table above.

3. Missing x-default

x-default tells Google “if the user’s language isn’t in any of the listed versions, send them here.” Skip it → Google guesses and may send an English user to your Japanese page.

<link rel="alternate" hreflang="en" href="..." />
<link rel="alternate" hreflang="zh" href="..." />
<link rel="alternate" hreflang="x-default" href="https://yourdomain.com/en/article/" />

Usually x-default points to your primary language (typically English).

4. hreflang URL targets 404 / redirect / noindex

<!-- on /zh/post -->
<link rel="alternate" hreflang="en" href="https://yourdomain.com/en/post/" />
<!-- but /en/post/ was deleted, or 301-redirects, or is noindexed -->

Google drops the entire pair.

How to confirm:

for u in /en/post /zh/post /ja/post; do
  echo -n "$u: "
  curl -sI -o /dev/null -w "%{http_code}\n" "https://yourdomain.com$u/"
done
# All should be 200, and none should have noindex in their head

5. Same language declared for two URLs

<!-- on /zh/post -->
<link rel="alternate" hreflang="zh" href="https://yourdomain.com/zh/post-v2/" />
<!-- declared the same lang twice -->

Each hreflang value (e.g., zh) can only appear once. Multiple entries → Google ignores all of them.

6. hreflang conflicts with canonical (the suicide pattern)

The most lethal mistake:

<!-- on /zh/post -->
<link rel="canonical" href="https://yourdomain.com/en/post/" />
<link rel="alternate" hreflang="zh" href="https://yourdomain.com/zh/post/" />
<link rel="alternate" hreflang="en" href="https://yourdomain.com/en/post/" />

The canonical pointing to English means “don’t index the Chinese page,” but hreflang is trying to make the Chinese page the destination for zh traffic. Google kicks the Chinese page out of the index entirely; all Chinese search traffic lands on the English page.

Right: canonical must always point to the current page itself.

Shortest path to fix

Step 1: Generate hreflang from one helper (never hand-write)

// src/lib/hreflang.js
const BASE = "https://yourdomain.com";
const LANGS = ["en", "zh-Hans", "ja"];  // every locale you support

export function buildHreflang(currentLang, slug) {
  const canonical = `${BASE}/${currentLang}/${slug}/`;
  const alternates = LANGS.map((lang) => ({
    hreflang: lang,
    href: `${BASE}/${lang}/${slug}/`,
  }));
  alternates.push({
    hreflang: "x-default",
    href: `${BASE}/en/${slug}/`,
  });
  return { canonical, alternates };
}

In the layout:

---
import { buildHreflang } from "../lib/hreflang.js";
const { canonical, alternates } = buildHreflang(Astro.props.lang, Astro.props.slug);
---
<link rel="canonical" href={canonical} />
{alternates.map(({ hreflang, href }) => (
  <link rel="alternate" hreflang={hreflang} href={href} />
))}

This guarantees every page emits the same hreflang array and auto-closes the loop.

Step 2: Crawl the build output to verify reciprocity

// scripts/check-hreflang.mjs
import fg from "fast-glob";
import fs from "node:fs";

const files = fg.sync("dist/**/*.html");
const map = new Map();  // url -> Map of hreflang -> url

for (const f of files) {
  const html = fs.readFileSync(f, "utf8");
  const canonical = html.match(/<link\s+rel=["']canonical["']\s+href=["']([^"']+)["']/i)?.[1];
  if (!canonical) continue;
  const alts = [...html.matchAll(/<link\s+rel=["']alternate["']\s+hreflang=["']([^"']+)["']\s+href=["']([^"']+)["']/gi)];
  map.set(canonical, new Map(alts.map(m => [m[1], m[2]])));
}

const issues = [];
for (const [url, alts] of map) {
  for (const [lang, altUrl] of alts) {
    if (lang === "x-default") continue;
    const reverseAlts = map.get(altUrl);
    if (!reverseAlts) issues.push(`${url} → ${altUrl} (${lang}): target not crawled`);
    else if (!reverseAlts.has(getMyLang(url))) issues.push(`${url} ↔ ${altUrl}: missing return tag`);
  }
}

function getMyLang(u) {
  return new URL(u).pathname.split("/")[1];  // /zh/foo → zh
}

console.log(issues.length ? issues.join("\n") : "All hreflang pairs closed ✓");

Run after build for a reciprocity check.

Step 3: Cross-check with a third-party tool

Step 4: Wait 7-21 days

  • Search Console → International Targeting takes 1-3 weeks to clear errors
  • Meanwhile, “Request indexing” on the top URLs to accelerate

Step 5: If Search Console errors don’t clear

  • Confirm the verified property in Search Console covers all locale subdirectories (not just root)
  • Check robots.txt isn’t blocking any locale directory
  • DevTools the page and confirm the <link> tags are inside <head> (in <body> they don’t count)

Prevention

  • All hreflang goes through one helper function — no hand-writing
  • Canonical always points to its own current locale; never cross-locale
  • When adding a new language, deploy return tags site-wide first; don’t “ship English now, add Chinese later”
  • CI runs the hreflang reciprocity check — one missing return tag fails the build
  • ISO codes everywhere: language ISO 639-1, region ISO 3166-1 alpha-2, script zh-Hans / zh-Hant

Tags: #SEO #Google #Search Console #Indexing