Multilingual SEO: The hreflang and Canonical Strategy We Rebuilt After an 87-Page Failure

All 87 of our docs pages declared the same two hreflang URLs, so Google ignored the lot. The path-derived fix, localized TR slugs, x-default and ?lang= 301s.

Multilingual SEO: The hreflang and Canonical Strategy We Rebuilt After an 87-Page Failure

All 87 of our docs pages declared the same two hreflang URLs, so Google ignored the lot. The path-derived fix, localized TR slugs, x-default and ?lang= 301s.

Eighty-seven pages, one hardcoded hreflang pair

jekcms.com serves every page in English and Turkish from a single codebase. English lives at unprefixed URLs, Turkish under /tr/ with localized slugs — /pricing becomes /tr/fiyatlandirma, /about becomes /tr/hakkimizda. The documentation section alone is 87 pages, each in both languages.

While grepping rendered <head> output across the docs section — the same habit that produced our fleet-wide SEO audit — I found this in every single docs page:

<link rel="alternate" hreflang="en" href="https://jekcms.com/docs">
<link rel="alternate" hreflang="tr" href="https://jekcms.com/tr/dokumantasyon">

All 87 pages. The same two URLs. The tags were hardcoded in the docs header partial, written back when the docs section was a single landing page, and every page added since inherited them unchanged.

Think about what that told Google. A page like /docs/automation/webhooks was claiming its Turkish alternate is the documentation landing page — wrong — and, worse, that its English alternate is /docs. Not itself. Some other page.

Why Google threw the whole cluster away

hreflang works in clusters. Every URL in a language group must list all alternates including itself, and every listed alternate must point back with a matching set. Miss the self-reference or the return link and the annotation fails validation — not partially; Google discards the signal for that page.

On our docs section, exactly two pages had valid annotations: /docs and /tr/dokumantasyon themselves, by accident, because the hardcoded pair happened to describe them. The other 85 published contradictions. The net effect: 87 pages of hreflang markup, and the usable signal was close to zero. Turkish docs pages kept surfacing for English queries and vice versa — precisely the problem hreflang exists to prevent.

The markup looked complete in view-source. That is what lets this class of bug survive for so long.

The fix: build the pair from the request path

The correct hreflang pair for any page is a function of the URL being served. So compute it there, at request time, instead of writing it into a template:

function hreflang_pair(): array {
    $path = strtok($_SERVER['REQUEST_URI'], '?');   // no query string
    $base = rtrim(SITE_URL, '/');

    if (str_starts_with($path, '/tr/')) {
        $tr = $path;
        $en = tr_to_en_path($path);   // slug map, see below
    } else {
        $en = $path;
        $tr = en_to_tr_path($path);
    }

    return ['en' => $base . $en, 'tr' => $base . $tr];
}

$pair = hreflang_pair();
foreach (['en', 'tr'] as $lang) {
    echo '<link rel="alternate" hreflang="' . $lang . '" href="'
        . htmlspecialchars($pair[$lang]) . '">' . "\n";
}
echo '<link rel="alternate" hreflang="x-default" href="'
    . htmlspecialchars($pair['en']) . '">' . "\n";

Details that matter in production:

  • The query string is stripped before anything else happens. Parameters never belong in hreflang or canonical URLs.
  • The self-reference falls out for free: the current page's own path is always one side of the pair.
  • Reciprocity also falls out for free. The Turkish page computes the same pair from its side of the map, so the two sets can never disagree.

That last point is the real lesson. Bidirectionality is not something you maintain by hand; it is something the architecture either guarantees or doesn't.

Localized Turkish slugs — worth the mapping table

We could have built Turkish URLs by prefixing: /tr/about, /tr/pricing. Computing the alternate would be one line of string math. We localized instead: /tr/hakkimizda, /tr/fiyatlandirma, /tr/dokumantasyon.

Two reasons. Turkish users searching Turkish words deserve Turkish words in the URL — the slug is both a relevance signal and a trust signal in the result snippet. And an English path segment in front of Turkish content reads like an afterthought, because it is one.

The cost is that en_to_tr_path() can't be string manipulation. For blog posts it's a lookup — every post row carries slug_en and slug_tr columns, so the map is the database. For static and docs pages it's an explicit array kept next to the router. Boring to write, and the boredom is the point: an explicit map has no silent failure mode. If a page is missing from it, the helper returns null, we skip the hreflang block for that page entirely, and a log line tells me to add the entry. A wrong-but-plausible generated URL would be far worse — that's exactly the bug class we had just cleaned up.

How we keep the two content trees in sync editorially is its own topic, covered in the dual-language workflow post.

x-default goes to English — a decision, not a default

The x-default annotation names the page for users who match neither language. Ours points at the English version: the audience for a PHP CMS outside Turkey overwhelmingly reads English, and the unprefixed tree is the primary one in our routing anyway.

If your primary market is Turkish, flip it. What you should not do is omit it, or aim it at a language-selection splash page that doesn't exist. It's one extra line per page, computed from the same pair.

?lang= is a redirect, not a rendering mode

The early language switcher on jekcms.com set ?lang=tr and rendered Turkish content at whatever URL you happened to be on. Once real /tr/ paths existed, that parameter became a liability: /docs?lang=tr is Turkish content on a URL whose canonical and hreflang both say "English page". Every such render is a duplicate-content variant chipping away at the clean signals.

Now any request carrying ?lang= gets a 301 to the path-based equivalent — issued before a single byte of output, because a redirect attempted after the template has started printing turns into a broken page instead of a redirect:

if (isset($_GET['lang'])) {
    $path   = strtok($_SERVER['REQUEST_URI'], '?');
    $target = $_GET['lang'] === 'tr' ? en_to_tr_path($path) : tr_to_en_path($path);
    header('Location: ' . ($target ?? $path), true, 301);
    exit;
}

301, not 302. The parameter URLs had years of history behind them, and a permanent redirect consolidates whatever equity they held onto the canonical paths. Within a few crawl cycles the parameter variants dropped out of the index.

Canonical strategy: self-referencing, per language, always

Every page prints exactly one canonical, pointing at itself. jekcms ships output_canonical_tag() for this and its default behaviour is the correct one. The Turkish page's canonical is the Turkish URL. Full stop.

The tempting mistake is the cross-language canonical: pointing the Turkish page at the English "original". That sends Google two contradictory messages — canonical says "I am a duplicate, index the other one", hreflang says "we are equal alternatives for different audiences". Google resolves the contradiction by trusting the canonical, and your Turkish page quietly leaves the index.

One more constraint: the canonical URL and the hreflang self-URL must be byte-identical. Same scheme, same host, same trailing-slash convention. We normalize both through the same path helper so they cannot drift apart.

Feeds split by language too

The RSS feed used to interleave both languages. Every subscriber got half their items in a language they don't read, and the feed's <language> element was wrong for half the entries. Feeds are also a discovery input for crawlers, so a mixed feed muddies the same signal the hreflang work had just cleaned.

The fix mirrors the URL scheme: an English feed on the unprefixed tree, a Turkish feed under /tr/, each declaring its own language:

<language>en-us</language>   <!-- English feed -->
<language>tr-tr</language>   <!-- Turkish feed -->

Each page's head links only the feed for its own language. A reader who subscribes from a Turkish page gets a Turkish feed. Obvious in hindsight.

Checking your own site in ten minutes

Skip the tooling debates; two commands find this entire class of bug:

# does a deep page reference ITSELF?
curl -s https://example.com/docs/some-deep-page | grep -i hreflang

# does its declared alternate point back?
curl -s https://example.com/tr/derin-sayfa | grep -i hreflang

Run it on a handful of deep pages, not the homepage. The homepage is the page most likely to be accidentally correct — ours was. If every page in a section prints identical hreflang URLs, you have our bug.

Then be patient. Google took weeks to fully reprocess the docs cluster after the fix, and Search Console no longer offers the old International Targeting report to watch it happen — URL Inspection on individual pages is the honest verification path now. The part you can verify on day one is the markup itself: each URL either declares itself and its true alternate, or it doesn't. After running 14 bilingual-and-monolingual sites on this codebase, my summary of hreflang is short: it isn't hard, it's just unforgiving of shortcuts.

Written by

Celil Uyanıkoğlu

Computer engineer with 25+ years in IT. He builds jekcms and runs his own network of content sites on it — every guide published here is tried on those live installs first.

See all posts →

Order Today

One-time payment, lifetime access. Setup in 30 minutes.

View Pricing
  • Setup and live in 30 minutes
  • 13 professional themes
  • AVIF/WebP image optimization
  • Automatic SEO — Sitemap, Schema.org
  • ZeroTrack cookieless analytics

Be the first to know

New features, release notes & CMS guides — a couple of emails a month, no spam.