You have an EN and a ZH version of every article. Search Console flags /en/your-article/ as “Duplicate without user-selected canonical”, or it marks one language as “Alternate page with proper canonical tag” and indexes the other language instead. The cause is almost always that the canonical link in your EN template points at the ZH URL (or vice versa), or that both versions name a single language as the canonical.
Fastest fix: make each language version self-canonical (the /en/ page canonicals to /en/, the /zh/ page canonicals to /zh/), and use hreflang to declare the two versions as alternates of each other. Those are two independent mechanisms, and mixing them up causes nearly every bug on this page. Skip to Shortest path to fix if you just want the commands.
One clarification before you panic: “Alternate page with proper canonical tag” is the correct, healthy end state for the version you did not choose as canonical. The problem is only when the wrong language got picked as canonical, or when one language shows “Duplicate without user-selected canonical” because no clear self-canonical exists.
How canonical and hreflang relate
Google reads rel="canonical" first to pick the representative URL, then reads hreflang to find that URL’s localized variants. Three facts decide whether this works:
rel="canonical"is a signal, not a directive. Google’s docs describe it as “a strong signal that the specified URL should become canonical,” but if your canonical points away from the page’s own language, or conflicts with other signals, Google may pick a different URL. (See How Google chooses canonical URLs.)- hreflang requires reciprocal return tags. Per Google’s docs, “Each language version must list itself as well as all other language versions,” and “if two pages don’t both point to each other, the tags will be ignored.” A self-canonical that points cross-language silently breaks the whole hreflang cluster.
- hreflang on a non-canonical page is discarded. Google’s documented rule is to “specify a canonical page in the same language” as the hreflang annotations. When a page canonicals to a different URL, Google treats that page’s hreflang as if it isn’t there — the canonical and hreflang signals must agree before Google trusts the cluster.
So the rule for a bilingual site is: canonical = the page’s own URL; hreflang = the full set of language versions, each listing itself and the others.
Which bucket are you in?
| Symptom in Search Console | Likely cause | Where to look |
|---|---|---|
| Wrong language indexed; other shows “Duplicate without user-selected canonical” | Both versions emit the same canonical | Compare curl canonical output for both URLs |
| Both versions canonical to one URL | Canonical derived from translationKey, not the page URL | Layout canonical helper |
ZH page canonicals to /en/... | Hardcoded /en/ prefix or default-locale fallback | Search template for hardcoded /en/ |
| Random pages wrong, others fine | Locale variable read wrong at render time | Log locale vs page lang in the helper |
| hreflang “no return tags” reported by a crawler | Canonical points cross-language, breaking reciprocity | Both pages’ <head> blocks |
| All canonicals wrong, every page | site in astro.config.mjs doesn’t match production | astro.config.mjs |
Common causes
Ordered by hit rate, highest first.
1. Template hardcoded one canonical URL
// Bad
<link rel="canonical" href="https://site.com/en/article" />
The same canonical is emitted on both the EN and ZH versions. Google sees ZH pointing to EN and de-duplicates by indexing only EN.
How to spot it:
curl -s https://site.com/zh/article/ | grep -i canonical
curl -s https://site.com/en/article/ | grep -i canonical
If both return the same URL, it’s hardcoded wrong.
2. translationKey used as canonical by mistake
Some Astro/Next templates use a shared translationKey to link versions and accidentally also use it to derive the canonical. Result: both versions canonical to one URL based on the key.
How to spot it: Look at your layout’s canonical derivation. If it uses translationKey instead of the actual page’s full URL (with locale prefix), this is it.
3. Default canonical falls back to default-language URL
// Bad
<link rel="canonical" href={`https://site.com/en/${slug}/`} />
The /en/ is hardcoded, so ZH pages get a canonical to the EN URL.
How to spot it: Search your template for hardcoded /en/ or /zh/ in canonical generation.
4. Canonical includes the locale param but with wrong locale
Off-by-one bug: the ZH page sees locale = 'en' from a global variable, so the canonical ends up wrong.
How to spot it: Add logging to your canonical helper. Compare the locale value at render time to the page’s actual lang.
5. Conflicting canonical + hreflang (broken return tags)
The canonical says /en/article/ and hreflang="zh" says /zh/article/, but the ZH page itself isn’t self-canonical. Google sees the conflict, the reciprocity check fails, and it picks one version (often the wrong one).
How to spot it: The ZH page’s <head> should contain exactly this (note the self-canonical and the full reciprocal set):
<link rel="canonical" href="https://site.com/zh/article/" />
<link rel="alternate" hreflang="en" href="https://site.com/en/article/" />
<link rel="alternate" hreflang="zh" href="https://site.com/zh/article/" />
<link rel="alternate" hreflang="x-default" href="https://site.com/en/article/" />
The EN page mirrors this with EN as its canonical. Both pages must list the same hreflang set, and each must list itself, or the cluster is ignored.
6. Astro site config has wrong base URL
If site in astro.config.mjs is wrong, every canonical derived from it is wrong.
How to spot it:
// astro.config.mjs
export default defineConfig({
site: 'https://site.com', // must match production exactly, incl. https and no trailing path
});
Then curl -s https://site.com/ | grep canonical should match site exactly.
Shortest path to fix
Step 1: Confirm each version is self-canonical
For each pair of EN/ZH articles:
for lang in en zh; do
echo "=== /$lang/article/ ==="
curl -s "https://site.com/$lang/article/" | grep -i canonical
done
Expected:
=== /en/article/ ===
<link rel="canonical" href="https://site.com/en/article/" />
=== /zh/article/ ===
<link rel="canonical" href="https://site.com/zh/article/" />
Both must be self-canonical. If one points to the other, you have the bug.
Step 2: Fix the template
In your layout (Astro example), derive the canonical from the real request URL, never from a hardcoded prefix or shared key:
---
const url = new URL(Astro.url.pathname, Astro.site).toString();
const lang = Astro.params.lang || 'en';
const translationKey = Astro.props.frontmatter?.translationKey;
const altLang = lang === 'en' ? 'zh' : 'en';
---
<link rel="canonical" href={url} />
<link rel="alternate" hreflang={lang} href={url} />
<link rel="alternate" hreflang={altLang} href={`${Astro.site}${altLang}/${translationKey}/`} />
<link rel="alternate" hreflang="x-default" href={`${Astro.site}en/${translationKey}/`} />
Note that the page lists itself in hreflang={lang} too — that self-reference is the return tag Google requires.
Step 3: Validate at build time
Add a prebuild script so a wrong canonical can never ship again:
import fs from 'node:fs';
import path from 'node:path';
import { parse } from 'node-html-parser';
const dist = 'dist';
let errors = 0;
function walk(dir) {
for (const f of fs.readdirSync(dir)) {
const p = path.join(dir, f);
if (fs.statSync(p).isDirectory()) walk(p);
else if (p.endsWith('.html')) {
const html = fs.readFileSync(p, 'utf8');
const root = parse(html);
const canonical = root.querySelector('link[rel="canonical"]')?.getAttribute('href');
const expected = 'https://site.com/' + p.replace(/^dist\//, '').replace(/index\.html$/, '');
if (canonical !== expected) {
console.error(`MISMATCH: ${p}\n canonical: ${canonical}\n expected: ${expected}`);
errors++;
}
}
}
}
walk(dist);
process.exit(errors ? 1 : 0);
Step 4: Verify hreflang reciprocity with a crawler
Important, as of June 2026: Search Console no longer has an hreflang report. The legacy International Targeting report was deprecated and removed on September 22, 2022, and Google does not surface site-wide hreflang errors anywhere in the console anymore (Google still uses hreflang; it just stopped reporting on it). Validate reciprocity with a crawler instead — Screaming Frog (Reports -> Hreflang -> “Non-Reciprocal” / “Missing Return Links”), Sitebulb, or Ahrefs Site Audit. The check you want: every alternate listed on page A must list page A back, and each page must list itself.
Step 5: Inspect the URLs in Search Console
In Search Console, open URL Inspection and submit a few of the ZH URLs that were previously misconfigured. The live test shows the “Google-selected canonical” vs the “User-declared canonical” — these should now agree and name the page’s own URL. Click Request indexing. Status moves from “Duplicate without user-selected canonical” to “Indexed” (the non-canonical twin will correctly read “Alternate page with proper canonical tag”). Reindexing typically takes a few days to about two weeks.
Step 6: Resubmit the sitemap
If your sitemap was built with the broken canonicals, regenerate and resubmit it. The sitemap should have one entry per URL — both the EN and ZH versions appear as separate entries — and each <url> may carry xhtml:link rel="alternate" hreflang annotations as an alternative to the in-page tags. (Pick one method; per Google, HTML tags, HTTP headers, and sitemap annotations are equivalent, and running all three is just harder to maintain.)
How to confirm it’s fixed
Run this combined check; all four lines should be true:
for lang in en zh; do
url="https://site.com/$lang/article/"
echo "== $url =="
curl -s "$url" | grep -iE 'rel="canonical"|hreflang'
done
You should see, for each page: a canonical that equals that page’s own URL, an hreflang line for its own language, an hreflang line for the other language, and an x-default. In Search Console’s URL Inspection, “Google-selected canonical” should equal “User-declared canonical.” A crawler should report zero “missing return links.”
FAQ
Should the ZH page canonical to the EN page since EN is my main language?
No. Each language version must be self-canonical. Canonicalizing ZH to EN tells Google the ZH page is a duplicate of EN, so the ZH page drops out of the index and never ranks for Chinese queries. Use hreflang, not canonical, to express the language relationship.
Is “Alternate page with proper canonical tag” an error I need to fix? Usually not — it is the expected status for the version that is not the canonical of a duplicate set. It only matters here if the wrong language was chosen as canonical. For two genuinely different language pages that are each self-canonical, both should end up “Indexed.”
Why is Google ignoring my hreflang tags entirely? Most often because the return tags aren’t reciprocal: page A lists page B, but B doesn’t list A back, or a page doesn’t list itself. Google’s docs are explicit that mismatched tags are ignored. A cross-language canonical also breaks this, because the canonical and hreflang signals must agree before Google trusts the cluster.
Where did the hreflang report in Search Console go? It was part of the International Targeting report, which Google deprecated and removed on September 22, 2022. As of June 2026 there is no hreflang report in Search Console (Google still reads hreflang; it just no longer reports errors on it). Use URL Inspection for single pages and a crawler (Screaming Frog, Sitebulb, Ahrefs) for site-wide reciprocity checks.
How long until the wrong canonical clears after I fix it? After deploy and Request indexing, expect a few days to about two weeks for high-value URLs; less-crawled pages can take longer. Resubmitting the sitemap and earning some internal links to the previously-misindexed pages speeds it up.
Prevention
- Template rule: canonical = the page’s own full URL, always. No exceptions.
- Keep canonical and hreflang as two independent mechanisms — canonical for “this version of this URL is the one”; hreflang for “this URL has translations elsewhere.”
- CI check that every rendered HTML’s canonical equals the file path it serves at.
- Don’t derive canonical from
translationKeyor any shared identifier — derive it from the URL. - Make sure every page lists itself in hreflang (the self-reference is the return tag).
- For new locales, test the canonical/hreflang output on a sample article before rolling out.