/ Hreflang Fixes / Canonical Conflict

How to Fix Hreflang vs Canonical Conflict

The English page declares /fr/about as its French equivalent via hreflang. The French page canonicals back to /en/about. Google sees one signal saying "these are alternates" and another saying "the French page is a duplicate of the English one". Canonical wins; hreflang is ignored. The cluster fails silently — French users see the English page in search, and the translated content never gets indexed. This guide covers the right canonical pattern for translated pages and the audit to find broken clusters.

1. The conflict in action

Wrong pattern

<!-- /en/about (English) -->
<link rel="canonical" href="https://example.com/en/about" />
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />

<!-- /fr/about (French) — BROKEN -->
<link rel="canonical" href="https://example.com/en/about" />  <!-- BAD: canonicals to English -->
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />

Google sees the French page declaring itself as a duplicate of the English. Drops it from the index. Hreflang annotation void. French users get the English page.

Right pattern: each variant self-canonicals

<!-- /en/about -->
<link rel="canonical" href="https://example.com/en/about" />
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />

<!-- /fr/about -->
<link rel="canonical" href="https://example.com/fr/about" />  <!-- self-canonical -->
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
<link rel="alternate" hreflang="fr" href="https://example.com/fr/about" />

Each variant tells Google "I'm the canonical version of this locale's content". Hreflang connects them as locale alternates. Google indexes both, serves each to the right audience.

2. Why this happens

The cross-locale canonical usually comes from one of these mistakes:

3. Audit current state

Step 1
Run the Hreflang Checker
Canonical conflict findings list every variant whose canonical doesn't match its served URL.
Step 2
Manual spot check
# Check canonical on each variant
for url in https://example.com/en/about https://example.com/fr/about https://example.com/de/about; do
  echo "=== $url ==="
  curl -s "$url" | grep -oE '<link[^>]*rel="canonical"[^>]*>'
done

# Each canonical href should equal the URL it's served from
Step 3
Search Console URL Inspection
Inspect each variant. "User-declared canonical" should equal the URL. "Google-selected canonical" should also equal it. If Google-selected differs from User-declared, Google overrode your signal.

4. Fix per platform

WordPress (WPML)

// Confirm WPML self-canonicals translated posts
// WPML → SEO → "Set hreflang and canonical tags"
//   - Mode: "Same URL per language" → each gets self-canonical
//   - Avoid "Use default language URL" if duplicate handling

WordPress (Polylang)

// Polylang handles self-canonicals correctly with Yoast/Rank Math
// If issues, check that:
// - Each translated post has Yoast canonical set to its OWN permalink (not source)
// - Yoast → Translation tab → confirm canonical settings

Next.js

// app/[locale]/about/page.tsx
export async function generateMetadata({ params }) {
  const url = `https://example.com/${params.locale}/about`;
  return {
    alternates: {
      canonical: url,  // self-canonical — uses the served locale
      languages: {
        'en': 'https://example.com/en/about',
        'fr': 'https://example.com/fr/about',
        'de': 'https://example.com/de/about',
        'x-default': 'https://example.com/en/about'
      }
    }
  };
}

Astro

---
// pages/[locale]/about.astro
const { locale } = Astro.params;
const canonical = `https://example.com/${locale}/about`;
---
<link rel="canonical" href={canonical} />
<!-- hreflang as before -->

Custom (vanilla template)

// In a partial/component
function getCanonical(currentLocale, slug) {
  return `https://example.com/${currentLocale}/${slug}`;
}

<link rel="canonical" href="${getCanonical('fr', 'about')}" />
// NEVER pick a default-locale URL here

5. Common edge cases

Edge case: country-specific subdomain

<!-- en.example.com/about -->
<link rel="canonical" href="https://en.example.com/about" />

<!-- fr.example.com/about -->
<link rel="canonical" href="https://fr.example.com/about" />

Same pattern — each subdomain self-canonicals. Cross-subdomain canonicals also void hreflang.

Edge case: ccTLD strategy

<!-- example.co.uk/about -->
<link rel="canonical" href="https://example.co.uk/about" />

<!-- example.fr/about -->
<link rel="canonical" href="https://example.fr/about" />

Each ccTLD canonicals within its own domain. Hreflang declares the cross-ccTLD alternates.

6. Verify after fixes

Step 1
Re-run Hreflang Checker
Canonical conflict findings clear. Every variant reports self-canonical.
Step 2
Search Console index recovery
For previously-canonicaled-away variants, request reindex via URL Inspection. Search Console "Indexed, though canonicalised to other URL" findings decrease over 2-4 weeks.
Step 3
site: query confirms recovery
site:example.com/fr/ now returns the French variants in results. Before the fix, only English versions appeared.
💡 The single rule: each locale variant canonicals to itself, regardless of which language it's translated from. Hreflang declares the cross-locale relationship separately. The two signals work together — canonical says "this URL is the right one for this content in this locale", hreflang says "and here are the other locales".

🌍 Re-run the Hreflang Checker

Verify each variant self-canonicals.

Run Hreflang Checker →
Related Guides: Hreflang Fixes  ·  Fix Self-Reference  ·  Fix Return Tags  ·  Hreflang Guide
💬 Got a problem?