Your bilingual site is hreflang-broken at the wiring level: EN and ZH pages declare each other as alternates but the declarations don’t reciprocate, the language codes don’t match, and one side is missing x-default. A crawler like Screaming Frog or Ahrefs lights up with “Missing reciprocal hreflang (no return-tag)” on dozens of pairs, plus a few “invalid language code” and “non-200 hreflang URL” rows. The consequence is concrete: when reciprocity is broken in either direction, Google ignores both annotations, so it can’t reliably swap the correct language version into the SERP and users get served the wrong locale.
Fastest fix: stop hand-writing hreflang. Auto-emit it from a single source of truth (translationKey) so every pair reciprocates by construction, pick one language code (zh or zh-CN) and use it everywhere, always include a self-referencing tag plus x-default, then validate one fixed URL with the free Merkle hreflang tester at technicalseo.com/tools/hreflang/ before you trust the rest.
One important change since older guides (and since this article was first written): Search Console’s International Targeting report — the old “Language / hreflang” error tab — was deprecated and removed by Google. Google still reads and honors hreflang, but it no longer surfaces hreflang errors in Search Console. As of June 2026 you must validate hreflang yourself with a third-party crawler or per-URL tester. Any guide that tells you to “check the International Targeting report” is out of date.
Which bucket are you in
| Symptom in your crawler / validator | Most likely cause | Jump to |
|---|---|---|
| ”Missing reciprocal hreflang (no return-tag)“ | One side doesn’t link back, or links a different URL | Cause 1 |
| ”Invalid / unknown language code” | zh vs zh-CN vs zh-Hans mismatch | Cause 2 |
| Return tag present but still flagged | Trailing-slash or http/https URL mismatch | Causes 3 and 6 |
| ”No self-reference” warning | Page omits an alternate pointing at itself | Cause 4 |
| Alternate URL returns 404 / 3xx | hreflang emitted for a translation that doesn’t exist | Cause 5 |
| Validator and crawler disagree | hreflang in sitemap and HTML head conflict | Cause 7 |
Common causes
1. One side declares the pair; the other doesn’t (no return tag)
The EN page declares ZH as its alternate. The ZH page does NOT declare EN as its alternate, or declares a different EN URL (old slug, http vs https, with/without trailing slash). Hreflang is bidirectional: if page A names page B as its alternate, page B must name page A back. If either direction is missing, Google discards both annotations entirely. This is the single most common hreflang failure.
How to spot it: fetch both pages and compare their link rel="alternate" blocks. Each must list the other with the exact same URL.
curl -s https://site.com/en/articles/foo/ | grep 'hreflang'
curl -s https://site.com/zh/articles/foo/ | grep 'hreflang'
The two outputs should reference each other’s URLs byte-for-byte.
2. Language code mismatch (zh vs zh-CN vs zh-Hans)
The EN page says hreflang="zh"; the ZH page says hreflang="zh-CN". The codes must be identical across the pair, or Google treats them as different declarations. Pick one convention site-wide.
Two facts that trip people up, both true as of June 2026:
- Hreflang uses an ISO 639-1 language code plus an optional ISO 3166-1 Alpha 2 region (so
zhorzh-CN, notzh-china). - Google does accept BCP-47 script subtags like
zh-Hans(Simplified) andzh-Hant(Traditional), but for a two-locale EN/ZH site they add precision you don’t need. The simplerzhorzh-CNis plenty. What actually breaks you is mixing conventions: if the EN page sayszh-Hansand the ZH page sayszh-CN, the pair doesn’t match and the annotations are discarded. Pick one form and use it everywhere. A genuinely malformed value (a typo likezh-china, or a code that isn’t a real ISO language) is what most validators flag as “invalid code.”
How to spot it: grep every hreflang code that appears in your templates.
grep -rn 'hreflang=' src/layouts/ src/components/
More than one code for the same language is the bug.
3. Trailing slash mismatch
The EN page declares the ZH alternate as https://site.com/zh/articles/foo, but the actual ZH URL is https://site.com/zh/articles/foo/ (trailing slash). Google treats these as two different URLs, so the return tag “doesn’t exist” at the URL it was promised.
How to spot it: compare your sitemap canonical URLs against your hreflang URLs. They must match byte-for-byte, trailing slash included.
4. Missing self-referencing tag
Each page should include an hreflang entry pointing at itself, in addition to its alternates. Google’s John Mueller has called the self-reference technically optional, but it is strongly recommended and most validators (including the Merkle tester) report “self-reference: not found” as a warning. The easiest way to guarantee it: emit one alternate per locale that exists, including the current page’s own locale.
How to spot it: the Merkle tester at technicalseo.com/tools/hreflang/ shows a “self-reference” status per URL. “Not found” means the page omits the self tag.
5. Missing x-default
You declared en and zh alternates but never declared x-default. Without it, Google has no instruction for users whose language/region matches neither variant. Best practice: x-default points at your primary language (EN here).
How to spot it:
curl -s https://site.com/en/articles/foo/ | grep 'x-default'
Empty result means x-default is missing.
6. Hreflang on pages that have no translation
You emit a zh alternate on every EN page, even ones with no ZH counterpart. The “alternate” points at a 404 or redirects to the root. A non-200 hreflang target is invalid, and the pair never reciprocates.
How to spot it: any declared alternate URL that returns a 3xx or 4xx. Screaming Frog flags these as “Non-200 hreflang URLs.”
7. Hreflang in sitemap and HTML head disagree
You can declare hreflang in the XML sitemap OR in the HTML head (or HTTP headers) — pick one. Doing two of them with conflicting values is the worst case: a crawler reading the head and a validator reading the sitemap will report different things, and Google sees contradictory signals.
How to spot it: compare hreflang in your sitemap entries against the page HTML. Any discrepancy is the bug. Standardize on one source.
Shortest path to fix
Step 1: Auto-emit hreflang from translationKey
Single source of truth. In the article layout, look up siblings by translationKey and emit one alternate per locale that actually has a counterpart. Because the current page is itself one of those siblings, this also produces the self-referencing tag automatically:
---
import { getCollection } from "astro:content";
const { article } = Astro.props;
const all = await getCollection("articles");
const siblings = all.filter(a => a.data.translationKey === article.data.translationKey);
const SITE = "https://site.com";
---
{siblings.map(s => (
<link
rel="alternate"
hreflang={s.data.lang === "zh" ? "zh-CN" : s.data.lang}
href={`${SITE}/${s.data.lang}/articles/${s.data.urlSlug}/`}
/>
))}
<link rel="alternate" hreflang="x-default" href={`${SITE}/en/articles/${article.data.urlSlug}/`} />
This guarantees reciprocity: if the ZH sibling exists, the EN page emits a ZH alternate AND the ZH page emits the EN alternate, from the same data. If a sibling does not exist, no alternate is emitted for that locale, which also fixes Cause 6.
Step 2: Pick one language code convention and apply it uniformly
The two reasonable choices:
- "zh" (language only) — simplest; fine if you have exactly one Chinese variant
- "zh-CN" (language+region) — explicit; recommended if you might add zh-TW later
For a simple EN/ZH site, prefer zh or zh-CN over the script subtags zh-Hans / zh-Hant — Google accepts the script forms, but they add granularity a two-locale site doesn’t need, and mixing them with region codes is a common source of mismatches. Whatever you choose, hard-code it in the template, then grep -rn 'hreflang=' to confirm no other code appears anywhere.
Step 3: Normalize URLs (trailing slash)
Astro’s default URL style includes trailing slashes for content collections. Lock it so hreflang, canonical, and sitemap all agree:
export default defineConfig({
trailingSlash: "always",
build: { format: "directory" },
});
Then confirm the URL emitted in each hreflang tag matches the page’s own rel="canonical" URL exactly. A mismatch here re-creates Cause 3 even after Step 1.
Step 4: Validate one URL before trusting the rest
After deploying, check a single representative pair with a per-URL tester, then crawl the whole site:
- technicalseo.com/tools/hreflang/ (Merkle, free) — paste a URL; shows self-reference status, tag count, and whether each alternate returns 200
- hreflang.org/ — older free validator; still works for a quick pair check
- Screaming Frog SEO Spider — site-wide crawl; "Hreflang" tab flags missing return links, bad codes, non-200 targets
- Ahrefs / Sitebulb Site Audit — hreflang health alongside the rest of the technical audit
There is no Search Console hreflang report anymore (the International Targeting report was removed), so these third-party tools are now the authoritative check.
Step 5: Add a prebuild assertion
Catch hreflang regressions before they ship. Assert that every translationKey group is internally consistent and that no group references a slug that doesn’t exist:
# scripts/audit-hreflang-pairs.mjs
import { getCollection } from "astro:content";
const all = await getCollection("articles");
const byKey = new Map();
for (const a of all) {
if (!a.data.translationKey) continue;
if (!byKey.has(a.data.translationKey)) byKey.set(a.data.translationKey, []);
byKey.get(a.data.translationKey).push(a);
}
let problems = 0;
for (const [key, group] of byKey) {
// every member of a group must share the SAME urlSlug so EN/ZH URLs pair cleanly
const slugs = new Set(group.map(a => a.data.urlSlug));
if (slugs.size > 1) {
console.error(`translationKey "${key}" has mismatched urlSlugs: ${[...slugs].join(", ")}`);
problems++;
}
// a group with two members of the same lang is a duplicate/typo
const langs = group.map(a => a.data.lang);
if (new Set(langs).size !== langs.length) {
console.error(`translationKey "${key}" has duplicate langs: ${langs.join(", ")}`);
problems++;
}
}
console.log(`Hreflang audit: ${problems} problems`);
process.exit(problems > 0 ? 1 : 0);
Wire it into your prebuild so a broken pair fails the build instead of reaching Google.
Step 6: Re-crawl and request indexing on the fixed pages
Google re-crawls on its own schedule. To speed it up, use Search Console’s URL Inspection tool and “Request indexing” on a sample of the fixed pages so the corrected tags get re-read. Then re-run your crawler (Step 4) after the next crawl to confirm the “no return-tag” count has dropped to zero. This takes days, not minutes.
How to confirm it’s fixed
- Run the Merkle tester on one EN URL and its ZH twin. Both should show: each other listed as alternates, a self-reference present,
x-defaultpresent, and every alternate returning HTTP 200. - Crawl the full site in Screaming Frog (or Ahrefs Site Audit). The “Missing return links” / “no return-tag” count should be 0, and there should be no “non-200 hreflang URL” rows.
- View source on a live page and confirm the hreflang URLs match the page’s
rel="canonical"exactly, trailing slash included. - Your prebuild assertion (Step 5) exits 0.
FAQ
Where did the Search Console hreflang report go? Google deprecated and removed the International Targeting report, which contained the “Language” / hreflang error tab. Google still reads hreflang, but it no longer reports hreflang errors in Search Console. Validate with a third-party crawler (Screaming Frog, Ahrefs, Sitebulb) or the per-URL Merkle tester instead.
Should I use zh or zh-CN?
Either is valid. Use zh if you have exactly one Chinese version. Use zh-CN if you might add Traditional Chinese (zh-TW) later, so the codes stay unambiguous. Google also accepts script subtags like zh-Hans / zh-Hant, but they’re more than a two-locale site needs and easy to mix up with region codes. The one rule that matters most: be consistent everywhere.
Do I really need a self-referencing hreflang tag?
Google’s John Mueller has said it’s technically optional, but it’s strongly recommended and most validators warn when it’s missing. The auto-emit template in Step 1 includes it for free because the current page is one of its own translationKey siblings.
What does “no return tag” actually mean? Page A lists page B as an alternate, but page B does not list page A back (or lists a slightly different URL). Hreflang must be reciprocal; when one direction is missing, Google discards both annotations, so neither page benefits. Fixing reciprocity at the source (Step 1) eliminates the whole class of error.
Should hreflang live in the HTML head or the XML sitemap? Either works. Don’t do both with different values. For a content site that already renders a per-article layout, the HTML head is usually simplest. Pick one source and make sure nothing else emits a conflicting set.
How long until Google reflects the fix? Days, not minutes. Google has to re-crawl both pages and re-evaluate the pair. Requesting indexing on a sample speeds it up; re-run your crawler after the next crawl to confirm the error count dropped.
Prevention
- Hreflang generated from
translationKey, never written by hand. - One language-code convention site-wide (e.g.
zh-CN), enforced by grep/lint; don’t mix it withzh-Hansor barezh. - Self-referencing tag and
x-defaultalways present, x-default pointing at the primary language. - Trailing-slash policy locked in Astro config; hreflang URLs match canonical URLs byte-for-byte.
- Prebuild assertion: every
translationKeygroup shares one slug, has no duplicate langs, and references no missing files. - External validator (Merkle tester or Screaming Frog) run after every major deploy.
- A quarterly site crawl, since Search Console no longer flags hreflang for you.
Related
- Bilingual Pages Drift Apart Over Time
- Content Site Translation Pages Mismatched
- Content Site Canonical Points to Self Wrong
- Content Site Sitemap Not Resubmitted After Big Changes
- Content Site FAQ Schema Not Extracted
- Search Console Low Value URLs
Tags: #Content ops #Site quality #Site audit #Troubleshooting #hreflang