TL;DR: Each page’s canonical must point at its own URL. If your ZH article’s <link rel="canonical"> points at the EN version, Google treats ZH and EN as one URL, keeps EN, and drops ZH from the index. The fast fix: compute the canonical from the current page’s own lang and slug in your layout, lock the trailing-slash policy, then verify with curl + view-source. Google ignores hreflang entirely on any page whose canonical points elsewhere, so this one bug silently kills the whole bilingual cluster.
You open Search Console, look at the Page indexing report, and your ZH pages sit under “Alternate page with proper canonical tag” — Google decided not to index them because their canonical points somewhere else. You view-source on a ZH article and find:
<link rel="canonical" href="https://site.com/en/articles/foo/">
The ZH page is canonicalizing itself to the EN version. To Google, ZH and EN are now the same URL, so the ZH version drops out of the index and half your bilingual investment goes invisible.
This is worse than it looks because of how canonical and hreflang interact. Per Google’s canonicalization docs, rel="canonical" is a strong signal (a hint, not a hard directive), and hreflang only works when every variant in the cluster carries a self-referencing canonical. When Googlebot hits a page whose canonical points to a different URL, the hreflang on that page is discarded as if it weren’t there. A 2026 LinkGraph audit of multilingual sites found roughly two-thirds had at least one canonical/hreflang conflict invalidating the cluster, so this is the single most common way bilingual SEO breaks.
The cause is almost always one of: an SEO plugin set to “use the primary language version,” or a layout that hard-codes the EN URL. The fix is conceptually trivial (each page’s canonical is its own URL) but the execution needs careful template work, a build-time check, and a one-time view-source verification across a sample of pages.
Which bucket are you in?
Run this on one ZH article and read the diagnosis off the canonical href:
curl -s https://site.com/zh/articles/foo/ | grep -i 'rel="canonical"'
| Canonical href you see | Bucket | Root cause |
|---|---|---|
.../en/articles/foo/ (wrong locale) | 1 or 2 | Plugin set to primary-language, or hard-coded EN URL |
.../zh/articles/foo (no trailing slash) | 3 | Canonical doesn’t match served trailing-slash policy |
.../zh/articles/foo/?utm_source=... | 4 | Query string / fragment baked into canonical |
https://medium.com/... (foreign host) | 5 | Cross-domain canonical pointing at a syndicated copy |
| no output at all | 6 | Canonical tag missing entirely |
.../zh/articles/foo/ (correct) | — | Canonical is fine; look at hreflang next |
Common causes
1. SEO plugin configured to canonicalize all translations to primary
Some plugins (and some custom layouts) have a “consolidate authority on the primary language” option. It sounds reasonable and is actually wrong. Hreflang already expresses the locale relationship; canonical should be self-referential. As Google’s docs state, hreflang on a non-canonical page is simply not used, so this setting both deindexes the translation and breaks hreflang.
How to spot it: view-source on a translated page and check the canonical link target.
curl -s https://site.com/zh/articles/foo/ | grep -i 'rel="canonical"'
If the href points at /en/, the plugin is mis-configured.
2. Hard-coded canonical in the layout
Someone wrote a canonical that always emits the EN URL, e.g. https://site.com/en/articles/<slug>/, in the article layout. That worked for EN pages and silently broke ZH pages.
How to spot it: grep the layout and components for canonical.
grep -rn 'rel="canonical"' src/layouts/ src/components/ src/pages/
If the URL doesn’t derive from Astro.url / Astro.site or the current page’s locale, it’s hard-coded.
3. Canonical trailing slash doesn’t match the served URL
The site serves /zh/articles/foo/ but the canonical declares /zh/articles/foo (no trailing slash). Google treats these as two different URLs, follows the canonical, indexes the slash-less variant, and your real URLs are deprioritized. Note that everything after the hostname is case-sensitive to Google too: /ZH/Articles/Foo/ and /zh/articles/foo/ are different URLs, so casing must match exactly.
How to spot it: compare the canonical href to the actual served URL, character for character. Trailing slash and case must match.
4. Canonical includes query strings or fragments
An author shared a URL with ?utm_source=twitter, and that pattern got cached or hard-coded into a template. Now the canonical carries query strings that fragment indexing. Google says canonical URLs should be the clean, absolute version with no tracking parameters.
How to spot it: the canonical href contains ? or #.
5. Cross-domain canonical pointing at a republished version
You syndicated an article to Medium or Substack. Someone correctly set the syndicated copy’s canonical back to your site, but also set your own site’s canonical to the Medium URL. Now your own page tells Google “I’m a copy of Medium,” and Google may index Medium instead of you.
How to spot it: any canonical hostname that doesn’t match the page’s own hostname.
6. Canonical missing entirely
No canonical tag at all. Google then chooses on its own (usually it self-canonicalizes correctly), but with URL variants in play — with/without trailing slash, with utm params, www vs non-www, http vs https — you lose deterministic indexing and Google may pick the wrong variant.
How to spot it: view-source for rel="canonical". Empty result means it’s missing.
Shortest path to fix
Step 1: Make canonical self-referential, per page
In your article layout, compute the canonical from the current page’s own lang and slug, never from a fixed locale. Use new URL(...) against Astro.site so the origin and trailing slash come straight from config:
---
const { article } = Astro.props;
// Astro.site carries the origin + base from astro.config.mjs
const canonical = new URL(
`${article.data.lang}/articles/${article.data.urlSlug}/`,
Astro.site,
).href;
---
<link rel="canonical" href={canonical} />
This guarantees ZH canonicalizes to ZH and EN to EN. Hreflang separately tells Google the pages are alternates — and because every variant now self-canonicalizes, Google will actually honor that hreflang.
One nuance from Google’s docs: rel="canonical" annotations that carry hreflang, lang, media, or type attributes are ignored for canonicalization. Keep your canonical link plain (no extra attributes) and put hreflang in separate <link rel="alternate" hreflang="..."> tags.
Step 2: Lock down trailing slash and casing
In astro.config.mjs:
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://site.com",
trailingSlash: "always",
build: { format: "directory" },
});
With trailingSlash: "always" and build.format: "directory", Astro emits foo/index.html and treats /foo/ as the canonical path; on-demand routes redirect the slash-less form to the slash form. Because Step 1 builds the canonical from Astro.site, it inherits this trailing slash automatically, so the canonical always matches what the server serves. Pick one casing policy (lowercase slugs) and keep slugs lowercase everywhere.
Step 3: Verify with curl + view-source on samples
Sample one article in each language and one in each major category:
for url in \
https://site.com/en/articles/foo/ \
https://site.com/zh/articles/foo/ \
https://site.com/en/articles/bar/ \
https://site.com/zh/articles/bar/
do
echo "=== $url ==="
curl -s "$url" | grep -iE 'rel="(canonical|alternate)"'
done
Each page’s canonical should match its own URL exactly. Each pair’s hreflang alternates should reciprocate (EN lists ZH, ZH lists EN, and both list themselves). If curl shows the right canonical but Search Console still complains, you are likely looking at a stale crawl — recheck after re-crawl (Step 5).
Step 4: Add a postbuild assertion
Catch regressions before they ship. This walks the built dist/ and fails the build if any page’s canonical doesn’t match its own path:
# scripts/audit-canonical.mjs
import fs from "node:fs";
import path from "node:path";
const SITE = "https://site.com";
const distRoot = "dist";
let problems = 0;
function walk(dir) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) { walk(p); continue; }
if (!p.endsWith("index.html")) continue;
const html = fs.readFileSync(p, "utf8");
const m = html.match(/<link\s+rel="canonical"\s+href="([^"]+)"/i);
if (!m) { console.error(`MISSING canonical: ${p}`); problems++; continue; }
// Reconstruct expected URL from the file path
const rel = p.replace(/^dist/, "").replace(/index\.html$/, "");
const expected = `${SITE}${rel}`;
if (m[1] !== expected) {
console.error(`WRONG canonical: ${p} -> ${m[1]} (expected ${expected})`);
problems++;
}
}
}
walk(distRoot);
process.exit(problems > 0 ? 1 : 0);
Wire it to a postbuild step (e.g. "postbuild": "node scripts/audit-canonical.mjs").
Step 5: Request reindexing on previously deindexed pages
In Search Console, open the Page indexing report, click into the “Alternate page with proper canonical tag” group, and once the canonical is fixed click Validate Fix to ask Google to recheck the whole group. For a few high-priority URLs, also run the URL Inspection tool and click Request Indexing to nudge an individual recrawl.
Set expectations on timing (as of June 2026):
- Validate Fix validation typically runs up to about two weeks, sometimes longer.
- Request Indexing recrawls usually land in about 2 to 10 days; it’s a hint, not a guarantee.
- The URL Inspection tool caps individual submissions at roughly 10 to 12 URLs per day per property, so don’t try to push the whole site through it — submit the sitemap for bulk recrawl instead.
The fix only takes effect on Google’s next crawl, so nothing changes in the report until that recrawl happens.
How to confirm it’s fixed
curl -s <zh-url> | grep -i 'rel="canonical"'returns the page’s own ZH URL, with the correct trailing slash and casing.- In Search Console URL Inspection, “User-declared canonical” and “Google-selected canonical” both show the page’s own URL (not the EN twin).
- The hreflang alternates reciprocate across the EN/ZH pair (each lists both variants plus itself).
- Over the following one to two weeks, the affected ZH pages move out of “Alternate page with proper canonical tag” and into Indexed.
FAQ
Is “Alternate page with proper canonical tag” always a problem?
No. On a single-language site it’s usually benign and just means Google found a duplicate (a ?utm variant, a paginated page, an AMP copy) and correctly consolidated it. It’s only a bug when a page you want indexed — like your ZH translations — is being treated as an alternate of a different-language page. Check the canonical href: if it points at another locale, that’s the bug.
Should I canonicalize my ZH page to the EN page to “consolidate authority”? No. That’s the exact mistake that deindexes the translation. Translations are not duplicates; they’re alternates. Use a self-referencing canonical on each language version and let hreflang express the relationship. Canonicalizing across languages also makes Google discard your hreflang entirely.
Do I still need hreflang if every page self-canonicalizes? Yes. Self-referencing canonicals keep each page indexable; hreflang tells Google which version to show to which audience. They do different jobs and you need both. Hreflang must be bidirectional — EN points to ZH, ZH points back to EN, and each includes a self-reference.
My canonical looks correct in curl but Search Console still flags it. Why?
Search Console shows the state from Google’s last crawl, which can lag the live HTML by days or weeks. Confirm the live HTML with curl, then use Validate Fix and Request Indexing, and wait for the recrawl. Don’t keep re-editing the template if curl already shows the right canonical.
Does the trailing slash really matter?
Yes. /zh/articles/foo and /zh/articles/foo/ are two different URLs to Google, and so is any case difference after the hostname. The canonical must match the served URL byte-for-byte: same protocol, same host (www vs non-www), same case, same trailing-slash policy.
Where do query strings like ?utm_source fit in?
They never belong in a canonical. The canonical should be the clean, absolute, parameter-free URL. Tracking parameters create duplicate URLs that fragment your indexing signals; let the canonical point all of them at the one clean version.
Prevention
- Canonical computed from the current page’s own URL via
Astro.site, never hard-coded to one locale. - Trailing-slash and casing policy locked in
astro.config.mjs; canonical matches the served URL exactly. - Postbuild audit: every page has a canonical matching its own URL, or the build fails.
- SEO plugin (if any) configured to NOT consolidate translations.
- No query strings, fragments, or extra
<link rel="canonical">attributes (hreflang/lang/media/type). - Cross-domain syndication: canonical-to-self on the original; only the syndicated copy canonicalizes back to you.
- Sample view-source check after any major template change.
Related
- Content Site Hreflang Tags Misconfigured
- Bilingual Pages Drift Apart Over Time
- Content Site Translation Pages Mismatched
- Content Site Sitemap Not Resubmitted After Big Changes
- Search Console Low Value URLs
- Content Site FAQ Schema Not Extracted
Tags: #Content ops #Site quality #Site audit #Troubleshooting #Canonical