Hreflang Tags Misconfigured: Search Console Flags Errors Between EN and ZH

Hreflang URLs don't pair, wrong language codes (zh vs zh-CN), missing x-default. Auto-emit from translationKey, validate with hreflang.org, fix at the source.

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-default always 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

Tags: #Content ops #Site quality #Site audit #Troubleshooting #hreflang