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
| Wrong | Right | Why |
|---|---|---|
zh | zh or zh-Hans / zh-Hant | zh alone works, but Simplified and Traditional collide if both use it |
cn | zh-CN | cn is a region, not a language |
tw | zh-TW | Same |
zh-hk | zh-HK | Region code must be uppercase (Google tolerates lowercase but other tools don’t) |
en-us | en-US | Same |
en-uk | en-GB | UK isn’t an ISO country code; GB is |
zh-CN-Hans | zh-Hans-CN | Script before region |
jp | ja | Japanese 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
- hreflang.org checker: paste two URLs, get a return-tag verification
- Screaming Frog: free for 500 URLs, has hreflang audit built in
- Ahrefs Site Audit: paid but the most detailed report
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, regionISO 3166-1 alpha-2, scriptzh-Hans / zh-Hant
Related
- Misconfigured canonical
- Alternate page with proper canonical tag
- Canonical wrong after domain change
Tags: #SEO #Google #Search Console #Indexing