Search Console -> International Targeting -> Hreflang lights up red. “No return tags” on 80 pairs. “Invalid language code” on 12. “Unrecognized hreflang values” on a handful. The bilingual site you built is hreflang-broken at the wiring level — pages declare each other as alternates but the declarations don’t reciprocate, the language codes don’t match what Google expects, and one side is missing the x-default. Result: Google can’t reliably swap the correct language version into SERP, so users get served the wrong locale.
Hreflang is unforgiving. Both pages in a pair must declare each other; both must use the same language code; the URLs must be exact matches (trailing slash counts); and x-default should point at a sensible fallback. The fix is to auto-emit hreflang from a single source of truth (translationKey), validate with a third-party hreflang validator, and never write hreflang tags by hand again.
Common causes
1. One side declares the pair; the other doesn’t
EN page declares ZH as alternate. ZH page does NOT declare EN as alternate (or declares a different EN URL). Google requires reciprocal declaration. Search Console reports “No return tags.”
How to spot it: fetch both pages, grep their link rel="alternate" blocks, confirm both list each other.
curl -s https://site.com/en/articles/foo/ | grep 'hreflang'
curl -s https://site.com/zh/articles/foo/ | grep 'hreflang'
2. Language code mismatch (zh vs zh-CN vs zh-Hans)
EN page says hreflang="zh"; ZH page says hreflang="zh-CN". Google sees a self-referential mismatch — the pages declare each other as alternates but with inconsistent codes. Either everyone uses zh or everyone uses zh-CN.
How to spot it: grep for hreflang codes in your templates:
grep -rn 'hreflang=' src/layouts/ src/components/
Different codes used in different places — that’s the bug.
3. Trailing slash mismatch
EN declares ZH alternate as https://site.com/zh/articles/foo. The actual ZH URL is https://site.com/zh/articles/foo/ (trailing slash). Google sees these as different URLs and counts the declaration as broken.
How to spot it: check your sitemap canonical URLs against your hreflang URLs — they must match byte-for-byte.
4. Missing x-default
You declared en and zh alternates but never declared x-default. Without it, Google guesses for users from countries that match neither. Best practice: x-default points at EN (or your primary language).
How to spot it: grep for x-default in template output:
curl -s https://site.com/en/articles/foo/ | grep 'x-default'
Empty result — missing x-default.
5. 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 at the root. Google logs that as broken.
How to spot it: a page declares alternates whose URLs do not resolve.
6. Hreflang only in sitemap, not in HTML head (or vice versa)
You can use either sitemap or HTML-head hreflang; doing both with disagreement is the worst case. Pick one and stick to it.
How to spot it: compare hreflang in sitemap vs page HTML. Any discrepancy is broken.
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:
---
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 a sibling does not exist, no alternate is emitted for that locale.
Step 2: Pick a language code convention and apply uniformly
The two reasonable choices:
- "zh" (language only) — simpler, fine if you only have one ZH variant
- "zh-CN" (language + region) — explicit, recommended if you might add zh-TW later
Pick one. Update the template. Verify with grep that no other code appears.
Step 3: Normalize URLs (trailing slash)
Astro’s default URL style includes trailing slashes for content collections. Lock this in astro.config.mjs:
export default defineConfig({
trailingSlash: "always",
build: { format: "directory" },
});
And ensure the URL in hreflang exactly matches the canonical URL on the page.
Step 4: Validate with a third-party tool
After deploying, run an external validator:
- https://hreflang.org/ — paste a URL, see pairs and errors
- https://www.aleydasolis.com/english/international-seo-tools/hreflang-tags-generator/ — generator + checker
- Search Console -> International Targeting -> Hreflang errors report
Fix any flagged issues. Re-validate.
Step 5: Add a prebuild assertion
Catch hreflang regressions before deploy:
# 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) {
const langs = new Set(group.map(a => a.data.lang));
if (langs.size === 1) {
// single-language; hreflang must NOT emit alternates
// (assert via template behavior; this is fine, not a problem)
}
}
console.log(`Hreflang audit: ${problems} problems`);
process.exit(problems > 0 ? 1 : 0);
Step 6: Re-fetch and resubmit affected URLs
In Search Console, request indexing on a sample of fixed pages so Google re-crawls and sees the corrected tags. The hreflang errors report updates over days, not minutes.
Prevention
- Hreflang generated from
translationKey, never written by hand - One language-code convention site-wide (e.g.
zh-CN), enforced by lint - Trailing-slash policy locked in Astro config; hreflang URLs match canonical URLs
x-defaultalways present, pointing at primary language- Prebuild assertion: every pair declares each other with matching codes
- External validator (hreflang.org or similar) run after major deploys
- Search Console hreflang error report reviewed monthly
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