Canonical Points to the Wrong Page: Translations Canonicalize Back to English

ZH pages carry a canonical that points at the EN version, so Google deindexes the ZH variant. Fix: self-referential per-page canonical, verify with curl + view-source.

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 seeBucketRoot cause
.../en/articles/foo/ (wrong locale)1 or 2Plugin set to primary-language, or hard-coded EN URL
.../zh/articles/foo (no trailing slash)3Canonical doesn’t match served trailing-slash policy
.../zh/articles/foo/?utm_source=...4Query string / fragment baked into canonical
https://medium.com/... (foreign host)5Cross-domain canonical pointing at a syndicated copy
no output at all6Canonical 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

  1. curl -s <zh-url> | grep -i 'rel="canonical"' returns the page’s own ZH URL, with the correct trailing slash and casing.
  2. In Search Console URL Inspection, “User-declared canonical” and “Google-selected canonical” both show the page’s own URL (not the EN twin).
  3. The hreflang alternates reciprocate across the EN/ZH pair (each lists both variants plus itself).
  4. 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.

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