Mert Özen Her Satırda Daha İleri
SEO ve GEO Teknik SEO — #10

hreflang ve Çok Dilli Site: TR/EN İçin URL Stratejisi ve x-default

Mert Özen 8 Kas 2026 11 dk 3 görüntülenme
hreflang ve Çok Dilli Site: TR/EN İçin URL Stratejisi ve x-default

Her dil kendi URL'sinde ve kendi canonical'ında olmalı, hreflang bunları karşılıklı bağlar; Googlebot Accept-Language göndermediği için dili cookie veya header'a göre değiştiren sayfanın diğer sürümü crawler için yok olur. ASP.NET Core'da route'tan gelen dil ve hreflang tag helper'ı, Angular i18n'de Lighthouse'un kaçırdığı canonical hatası ve mertozen.com'un ?lang='den /en/'e geçişi.

Sürüm notu: Bu yazıdaki kodlar .NET 10 (ASP.NET Core 10 Razor Pages ve Request Localization) ile Angular 22.2 ve @angular/localize 22.2.5 ile derlenip denenmiştir; hreflang kuralları Google Search Central'ın Kasım 2026 hâli esas alınarak anlatılmıştır.

Önce Tahmin Et

Bookstore Türkçe ve İngilizce yayın yapıyor. İlk kurulumda her kitabın tek bir URL'si (Uniform Resource Locator) var: /books/dune. Dil, tarayıcının Accept-Language header'ına ve kullanıcı dil seçtiyse bir cookie'ye göre belirleniyor. Kullanıcılar memnun: Türk okur Türkçe, Alman okur İngilizce görüyor. Ekip "dil başına ayrı URL'ye gerek yok" diyor.

İki soru. Googlebot bu sayfanın İngilizce sürümünü hiç görür mü? Ve ekip sonradan İngilizce için /en/books/dune açar ama İngilizce sayfanın canonical'ını unutup Türkçe adrese bırakırsa, Lighthouse bunu yakalar mı?

Serinin beşinci yazısında çevirinin duplicate olmadığını, Türkçe ve İngilizce sayfaların her birinin kendi canonical'ı olduğunu ve aralarındaki ilişkiyi hreflang'ın anlattığını söylemiştik. Bu yazıda o ilişkiyi kuruyoruz: URL stratejisi, hreflang'ın kuralları, ASP.NET Core ve Angular uygulaması ve mertozen.com'un 7 Kasım 2026'da yaptığı geçiş.

URL Stratejisi: Dört Seçenek

Google'ın çok bölgeli ve çok dilli siteler dokümanı dört URL yapısını karşılaştırıyor:

YapıÖrnekArtıEksi
Ülke kodlu domain (ccTLD: country-code Top-Level Domain)bookstore.deNet coğrafi hedeflemePahalı, tek ülkeye bağlı, her domain ayrı yönetilir
Subdomainen.bookstore.exampleKurulumu kolay, ayrı sunucu mümkünKullanıcı en'in dil mi ülke mi olduğunu anlamayabilir
Alt klasörbookstore.example/en/Kurulumu ve bakımı kolay, tek hostTek sunucu konumu
URL parametresibookstore.example?lang=en-Google'ın tablosunda "önerilmez"

Bookstore gibi tek bir ekibin yönettiği iki dilli bir site için alt klasör en sade seçim: tek domain, tek sertifika, tek deploy, tek Search Console mülkü. Parametre seçeneğinin sorunu, dilin URL'nin "isteğe bağlı" bir parçasına dönüşmesi: parametresiz URL'nin hangi dilde olduğu belirsiz kalıyor ve parametreler zaten canonical'dan atılan şeyler.

Google'ın dokümanındaki iki kural daha bu kararı etkiliyor. Birincisi, dil sürümleri için ayrı URL kullan; aynı URL'de cookie'ye veya tarayıcı ayarına göre dil değiştirmek önerilmiyor. İkincisi, Google sayfanın dilini görünür içerikten tespit ediyor; lang attribute'u ve hreflang dil tespitinde kullanılmıyor. Yani bir sayfanın içeriği ve navigasyonu tek dilde olmalı.

hreflang'ın Kuralları

hreflang, bir sayfanın diğer dil ve bölge sürümlerini Google'a bildirir; sonuçlarda kullanıcının diline uygun sürümün gösterilmesine yardım eder. Üç eşdeğer yerde verilebilir: HTML'de (HyperText Markup Language) <link rel="alternate" hreflang="...">, HTTP (HyperText Transfer Protocol) yanıtında Link header'ı (PDF gibi HTML olmayan dosyalar için) ve sitemap'te xhtml:link. Google'a göre üçünü birden kullanmanın ek faydası yok; birini seç ve tutarlı uygula. Kurallar:

  • Her sürüm kendisini ve diğer bütün sürümleri listeler; set her sayfada aynıdır.
  • Karşılıklılık şart: A, B'yi gösteriyorsa B de A'yı göstermeli; göstermiyorsa annotation yok sayılır.
  • URL'ler protocol dahil tam yazılır; farklı domain'lerde olabilir.
  • Dil kodu ISO (International Organization for Standardization) 639-1 standardındaki iki harfli kod, isteğe bağlı bölge kodu ISO 3166-1 Alpha 2. Bölge tek başına kullanılamaz; en-UK geçersiz, doğrusu en-GB.
  • x-default, kullanıcının dili hiçbir sürüme uymadığında gösterilecek sayfayı belirtir; dil seçici sayfalar ve otomatik yönlendiren ana sayfalar için düşünülmüş.

Bir kural da hreflang ile canonical'ın kesişiminde: hreflang'ın gösterdiği her URL kendi canonical'ı olmalı. hreflang bir non-canonical URL'yi gösterirse, Google o URL'yi canonical'ına katlarken hreflang sinyali anlamını yitirir. Google'ın canonical dokümanı da karşılıklı hreflang kümelerindeki URL'leri canonical seçerken tercih ettiğini söylüyor; iki sinyal birbirini destekleyecek şekilde kurulmalı.

Locale-Adaptive Sayfa: Googlebot Accept-Language Göndermez

Açılıştaki ilk sorunun cevabı Google'ın dokümanında açık: Googlebot genellikle ABD'den crawl ediyor ve isteklerinde Accept-Language header'ı göndermiyor. İçeriği dil ayarına göre değiştiren bir sayfanın bütün varyasyonları bu yüzden bulunup crawl edilemeyebilir. Bookstore'un Razor Pages demo'sunu iki modda çalıştırdım. "Adaptive" modda ASP.NET Core'un varsayılan culture provider'ları açık (query string, cookie, Accept-Language):

$ LANG_MODE=adaptive dotnet run --no-launch-profile --urls http://localhost:20111
$ curl -s http://localhost:20111/en/books/dune | grep -oE '<html lang="[a-z]+"|<link rel="canonical"[^>]*>|<p>.*</p>'
<html lang="tr"
<link rel="canonical" href="https://bookstore.example/en/books/dune" />
<p>Bir &#xE7;&#xF6;l gezegeni, soylu bir aile ve herkesin istedi&#x11F;i baharat.</p>

$ curl -s -H "Accept-Language: en" http://localhost:20111/books/dune | grep -oE '<html lang="[a-z]+"|<link rel="canonical"[^>]*>|<p>.*</p>'
<html lang="en"
<link rel="canonical" href="https://bookstore.example/books/dune" />
<p>A desert planet, a noble family and the spice everyone wants.</p>

İki ayrı felaket. Birincisi: İngilizce URL'yi Accept-Language göndermeden isteyen bir istemci, yani Googlebot, Türkçe içerik alıyor; İngilizce sayfa Google için hiç var olmuyor. İkincisi: Türkçe URL, İngilizce tarayıcı ayarıyla istenince İngilizce içerik dönüyor ama canonical'ı Türkçe adres. Aynı URL ziyaretçiye göre farklı dilde içerik dönüyor; açılıştaki ekibin "kullanıcılar memnun" dediği şey, crawler için tutarsızlık.

ASP.NET Core: Dil Yalnız URL'den

Çözüm, dili tek bir kaynaktan, URL'den okumak. Türkçe kökte, İngilizce /en/ altında; aynı Razor Page iki route'tan erişiliyor ve culture route değerinden geliyor:

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages(o =>
    // aynı sayfa İngilizce alt klasörde: /en/books/{slug}
    o.Conventions.AddPageRoute("/Books/Detail", "{culture:regex(^en$)}/books/{slug}"));
builder.Services.AddHttpContextAccessor();

builder.Services.Configure<RequestLocalizationOptions>(o =>
{
    o.SetDefaultCulture("tr").AddSupportedCultures("tr", "en").AddSupportedUICultures("tr", "en");
    // dil yalnız URL'den gelir: aynı URL her ziyaretçiye aynı dil
    o.RequestCultureProviders = [new RouteDataRequestCultureProvider()];
});

WebApplication app = builder.Build();
app.UseRouting();
app.UseRequestLocalization();
app.MapRazorPages();
app.Run();

RouteDataRequestCultureProvider route değerlerini okuduğu için UseRequestLocalization, UseRouting'den sonra gelmeli. hreflang ve canonical'ı bir tag helper üretiyor; dilden bağımsız path'i bulup her dil için URL kuruyor:

[HtmlTargetElement("hreflang-links", TagStructure = TagStructure.WithoutEndTag)]
public sealed class HreflangTagHelper(IHttpContextAccessor accessor) : TagHelper
{
    private const string Site = "https://bookstore.example";
    private static readonly (string Lang, string Prefix)[] Languages = [("tr", ""), ("en", "/en")];

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        HttpRequest request = accessor.HttpContext!.Request;
        string path = request.Path.Value!;
        // dilden bağımsız path: "/en/books/dune" -> "/books/dune"
        string neutral = path.StartsWith("/en/", StringComparison.Ordinal) ? path[3..] : path;
        string current = path.StartsWith("/en/", StringComparison.Ordinal) ? "en" : "tr";

        output.TagName = null; // yalnız içeriği yaz
        output.Content.AppendHtml($"<link rel=\"canonical\" href=\"{Url(current, neutral)}\" />\n");
        foreach ((string lang, _) in Languages)
            output.Content.AppendHtml($"<link rel=\"alternate\" hreflang=\"{lang}\" href=\"{Url(lang, neutral)}\" />\n");
        // x-default: dili yukarıdakilerin hiçbirine uymayan kullanıcıların sayfası
        output.Content.AppendHtml($"<link rel=\"alternate\" hreflang=\"x-default\" href=\"{Url("tr", neutral)}\" />");
    }

    private static string Url(string lang, string neutralPath) =>
        Site + Languages.Single(l => l.Lang == lang).Prefix + neutralPath;
}
$ curl -s http://localhost:20111/en/books/dune | grep -oE '<html lang="[a-z]+"|<link[^>]*>|<p>.*</p>'
<html lang="en"
<link rel="canonical" href="https://bookstore.example/en/books/dune" />
<link rel="alternate" hreflang="tr" href="https://bookstore.example/books/dune" />
<link rel="alternate" hreflang="en" href="https://bookstore.example/en/books/dune" />
<link rel="alternate" hreflang="x-default" href="https://bookstore.example/books/dune" />
<p>A desert planet, a noble family and the spice everyone wants.</p>

$ curl -s -H "Accept-Language: en" http://localhost:20111/books/dune | grep -oE '<html lang="[a-z]+"|<link[^>]*>|<p>.*</p>'
<html lang="tr"
<link rel="canonical" href="https://bookstore.example/books/dune" />
<link rel="alternate" hreflang="tr" href="https://bookstore.example/books/dune" />
<link rel="alternate" hreflang="en" href="https://bookstore.example/en/books/dune" />
<link rel="alternate" hreflang="x-default" href="https://bookstore.example/books/dune" />
<p>Bir &#xE7;&#xF6;l gezegeni, soylu bir aile ve herkesin istedi&#x11F;i baharat.</p>

Her URL artık her istemciye aynı dili dönüyor; canonical kendi URL'si, hreflang seti iki sayfada da aynı ve karşılıklı. Accept-Language hâlâ işe yarar bir bilgi, ama yeri içeriği değiştirmek değil: Türkçe sayfada İngilizce tarayıcılı bir kullanıcıya "Bu sayfanın İngilizcesi var" diye bir bant göstermek için kullanılabilir.

Çıktıdaki &#xE7; gibi karakterler de dikkatini çekmiş olabilir. Razor'un varsayılan HTML encoder'ı yalnız Basic Latin aralığını olduğu gibi bırakıyor, Türkçe karakterleri entity'ye çeviriyor. Tarayıcılar ve Google bunu doğru okur, ama HTML büyür ve kaynak okunmaz olur. AddWebEncoders ile izin verilen aralığı genişletmek düzeltiyor:

builder.Services.AddWebEncoders(o => o.TextEncoderSettings = new TextEncoderSettings(UnicodeRanges.All));
<p>Bir çöl gezegeni, soylu bir aile ve herkesin istediği baharat.</p>

Angular i18n: Locale Başına Build ve SSR

Angular'da çok dillilik build zamanında çözülüyor: @angular/localize, her locale için ayrı bir build üretiyor ve SSR (Server-Side Rendering) sunucusu isteği URL önekine göre doğru locale'in uygulamasına yönlendiriyor. Bookstore'un i18n kopyasında Türkçeyi kökte, İngilizceyi /en altında tutmak için angular.json'daki ayar:

"i18n": {
  "sourceLocale": { "code": "en", "subPath": "en" },
  "locales": {
    "tr": { "translation": "src/locale/messages.tr.xlf", "subPath": "" }
  }
}

Build seçeneklerinde "localize": true, template'lerde i18n attribute'ları (<h1 i18n="@@homeTitle">Science fiction classics</h1>), ng extract-i18n ile çıkarılan ve çevirisi eklenen bir messages.tr.xlf. Build her locale için dört sayfayı prerender etti (Prerendered 8 static routes.) ve dist/bookstore-ng/browser/index.html ile dist/bookstore-ng/browser/en/index.html ayrı ayrı oluştu. Angular her build'de <html lang>'i locale'e göre ayarlıyor ve <base href> sayesinde routerLink'ler kendiliğinden doğru önekle üretiliyor. İlk denemede ise bir hata vardı:

== /books/dune
<html lang="tr" <link rel="canonical" href="https://bookstore.example/books/dune"> <a routerlink="/" href="/">Ana Sayfa</a> ...
== /en/books/dune
<html lang="en" <link rel="canonical" href="https://bookstore.example/books/dune"> <a routerlink="/" href="/en/">Home</a> ...

İngilizce sayfanın canonical'ı Türkçe adresi gösteriyor. Serinin önceki yazılarından kalan SeoService, canonical'ı SITE + path diye kuruyordu ve locale'den habersizdi. Sonuç, Google'a "İngilizce sayfa Türkçe sayfanın kopyası" demek; İngilizce sayfanın index'ten düşmesi için yeterli bir sinyal. Açılıştaki ikinci sorunun cevabı da burada: bu build'de /en/books/dune için Lighthouse SEO (Search Engine Optimization) puanı 100 çıktı ve canonical denetimi "Document has a valid rel=canonical" dedi. Sayfada hreflang olmadığı için Lighthouse'un canonical'ın başka bir dil sürümünü gösterdiğini anlama yolu yoktu. Düzeltilmiş service, locale'i LOCALE_ID'den alıyor:

const SITE = 'https://bookstore.example';
// locale -> URL öneki; angular.json'daki "subPath" ile aynı olmalı
const PREFIX: Record<string, string> = { tr: '', en: '/en' };

@Injectable({ providedIn: 'root' })
export class SeoService {
  private readonly document = inject(DOCUMENT);
  private readonly title = inject(Title);
  private readonly meta = inject(Meta);
  private readonly locale = inject(LOCALE_ID); // "tr" veya "en", build başına sabit

  update(page: { title: string; description: string; path: string; noindex?: boolean }): void {
    this.title.setTitle(`${page.title} | Bookstore`);
    this.meta.updateTag({ name: 'description', content: page.description });
    this.document.head.querySelectorAll('link[rel="canonical"], link[rel="alternate"][hreflang]')
      .forEach(l => l.remove());
    if (page.noindex) {
      this.meta.updateTag({ name: 'robots', content: 'noindex' });
      return;
    }
    this.meta.removeTag('name="robots"');
    // canonical: bu dilin kendi URL'si, asla diğer dil değil
    this.addLink({ rel: 'canonical', href: this.url(this.locale, page.path) });
    for (const lang of Object.keys(PREFIX)) {
      this.addLink({ rel: 'alternate', hreflang: lang, href: this.url(lang, page.path) });
    }
    this.addLink({ rel: 'alternate', hreflang: 'x-default', href: this.url('tr', page.path) });
  }

  private url(lang: string, path: string): string {
    return SITE + PREFIX[lang] + (path === '/' && PREFIX[lang] ? '/' : path);
  }

  private addLink(attrs: Record<string, string>): void {
    const link = this.document.createElement('link');
    for (const [k, v] of Object.entries(attrs)) {
      link.setAttribute(k, v);
    }
    this.document.head.appendChild(link);
  }
}
$ curl -s http://localhost:20112/en/books/dune | grep -o '<html lang="[^"]*"\|<link rel="canonical"[^>]*>\|<link rel="alternate"[^>]*>'
<html lang="en"
<link rel="canonical" href="https://bookstore.example/en/books/dune">
<link rel="alternate" hreflang="tr" href="https://bookstore.example/books/dune">
<link rel="alternate" hreflang="en" href="https://bookstore.example/en/books/dune">
<link rel="alternate" hreflang="x-default" href="https://bookstore.example/books/dune">

Düzeltilmiş build'de Lighthouse'un hreflang denetimi de geçti ("Document has a valid hreflang"). Ders: Lighthouse hreflang kodlarının ve canonical'ın biçimini denetler, dil sürümleri arasındaki ilişkinin anlamını denetlemez. Bunun testi senin işin.

mertozen.com: ?lang='den /en/'e

Bu serinin planlandığı 7 Kasım 2026 sabahında mertozen.com, açılıştaki "adaptive" yapının bir varyantını kullanıyordu. Sitenin kaynak kodunun o günkü hâline göre: hreflang tr hedefi ?lang=tr, en hedefi ?lang=en, x-default parametresiz URL'ydi. ?lang=tr sayfası canonical'ını parametresiz URL'ye veriyordu, yani hreflang'ın gösterdiği Türkçe URL canonical değildi. Parametresiz URL ise dili cookie'ye ve Accept-Language'e göre seçiyor, İngilizce render edildiğinde canonical'ını ?lang=en'e veriyordu. Googlebot'a parametresiz URL hep Türkçe dönüyordu.

10-hreflang-canonical-tr.png

Aynı gün akşam yayınlanan v1.12.0 sürümü bu yazıda önerilen yapıya geçti. Commit mesajındaki gerekçe şu: önceki yapıda aynı URL ziyaretçinin dil çerezine ve Accept-Language'ine göre farklı dilde içerik ve canonical dönüyordu. Kaynak koddaki değişiklikler:

  • Türkçe kökte (/blog/x), İngilizce /en/ altında (/en/blog/x). Bir middleware /en önekini PathBase'e taşıyor; böylece controller ve route'lar hiç değişmedi.
  • Culture yalnız URL'den: query string, cookie ve Accept-Language provider'ları kaldırıldı, dil çerezi artık yazılmıyor.
  • Canonical ve hreflang URL'leri /en önekiyle üretiliyor; x-default Türkçe sayfa.
  • Eski ?lang=en ve ?lang=tr adresleri 301 ile yeni adreslere yönleniyor; /en de /en/'e.
  • CMS'te (Content Management System) tek adresle (Türkçe) yazılan yazı içi link'ler, İngilizce sayfada render edilirken /en önekine çevriliyor; İngilizce okur İngilizce sayfalarda kalıyor.
  • Sitemap her sayfa için Türkçe ve İngilizce iki kayıt üretiyor, ikisi de aynı alternate setini taşıyor.

Bu değişiklik teknik olarak bir site taşıması: bütün İngilizce URL'ler değişti. Taşımanın planı, redirect'lerin doğrulanması ve sonrasında izlenecekler serinin son yazısının konusu.

Üretimde Dikkat

Dil tercihine göre otomatik redirect yapma. Google bunu açıkça önermiyor; Googlebot'un bazı sürümleri hiç görmemesine yol açabilir. Kullanıcıya görünür bir dil seçici sun ve seçici aynı sayfanın diğer dildeki adresine link versin, ana sayfaya değil.

Bir sayfanın içeriği ve navigasyonu tek dilde olsun. Google dili görünür içerikten tespit ediyor; yarısı çevrilmiş şablonlar (Türkçe sayfada İngilizce menü) dil tespitini zayıflatır. <html lang>'i yine doğru ver: Google dil tespitinde kullanmasa da ekran okuyucular ve tarayıcının çeviri teklifi kullanıyor.

Çevrilmemiş içerik için hreflang verme. İngilizcesi olmayan bir yazının İngilizce URL'si Türkçe içerik dönüyorsa, hreflang iki aynı içerikli sayfayı farklı dillermiş gibi bildirir. Ya çevirisi olmayan sayfaları İngilizce tarafta yayınlama ya da hreflang setini sayfa bazında üret.

hreflang setini test et: her sayfanın setinde kendisi var mı, set karşılıklı mı, her hedef 200 dönüyor ve kendi canonical'ı mı? Bunlar küçük bir crawler script'iyle otomatikleştirilebilir; Lighthouse yetmez.

Sık Düşülen Tuzaklar

Aynı URL'de dili cookie veya Accept-Language ile değiştirmek. Googlebot header göndermez; diğer dil sürümü crawler için yok olur.

İngilizce sayfanın canonical'ını Türkçe sayfaya vermek. Çeviri duplicate değildir; her dil kendi canonical'ıdır.

hreflang'ı canonical olmayan URL'lere vermek (parametreli varyantlar gibi). Sinyal anlamını yitirir.

Karşılıksız hreflang. Türkçe sayfa İngilizceyi gösterip İngilizce sayfa Türkçeyi göstermiyorsa Google annotation'ı yok sayar.

Geçersiz kodlar: en-UK yerine en-GB, yalnız bölge (hreflang="GB") yerine dil artı bölge.

Küçük Bir Deneme

★☆☆ Kendi sitenden bir sayfanın hreflang setini curl -s ile al, her hedefi tek tek iste ve iki şeyi kontrol et: hedef 200 mü dönüyor, ve hedefin kendi hreflang setinde başlangıç sayfası var mı?

★★☆ HreflangTagHelper'ı bir sayfanın hangi dillerde çevirisi olduğunu bilecek şekilde genişlet: çevirisi olmayan dil için alternate üretmesin. Çevirisi yalnız Türkçe olan bir kitapla test et.

★★★ Bookstore için bir hreflang doğrulayıcı yaz: sitemap'teki her URL'yi isteyip HTML'deki hreflang setini çıkarsın, karşılıklılığı, self-reference'ı, kodların geçerliliğini ve her hedefin canonical'ının kendisi olduğunu kontrol etsin; ilk denemedeki Angular hatasını (İngilizce sayfanın canonical'ı Türkçe) yakaladığını göster.

mertozen.com'un geçişi bize iki konuyu bıraktı: crawler'ın bu kadar URL değişikliğini ne hızla işleyeceği ve taşımanın kendisinin nasıl yönetileceği. Bir sonraki yazıda crawl budget'ı, server log'larından bot trafiği analizini ve sahte Googlebot'ları ayırt etmeyi göreceğiz.