İçeriğe geç / Skip to content / Zum Inhalt
Ahmet Balaman LogoAhmet Balaman

Üç Dilli Bir Blogu hreflang'i Bozmadan Yönetmek

Ahmet Balaman

6 dk okuma

Vibe CodinghreflangTeknik SEONext.jsÇok Dilli SiteSchema.orgCore Web Vitals
Üç Dilli Bir Blogu hreflang'i Bozmadan Yönetmek

Bu sitede 400'e yakın yazı var ve her biri Türkçe, İngilizce ve Almanca yayımlanıyor. Üç dilin birbirine doğru bağlanması, yani hreflang, teorik olarak basit bir iş: her sayfa diğer dillerdeki karşılığını göstersin.

Pratikte ise sessizce bozulan bir şey. Bozulduğunda hata vermiyor, sayfa çalışmaya devam ediyor, sadece arama motoru iki sayfayı ayrı ve birbirinin kopyası gibi görmeye başlıyor.

Bu yazıda bu sitenin gerçek mimarisini anlatıyorum: hangi kararlar hangi somut sorunu çözdü, hangi hata bizzat canlıda ortaya çıktı. Kod örnekleri süs değil; hepsi çalışan koddan.

Sorun: iki sözlük asla senkron kalmıyor

İlk kurulumda çeviri eşleşmeleri iki ayrı sözlükte tutuluyordu: Türkçe'den İngilizce'ye bir tablo, İngilizce'den Türkçe'ye başka bir tablo. Mantıklı görünüyor — ta ki biri güncellenmeyi unutana kadar.

İki sözlük kullanıldığında hreflang tek yönlü kalıyor; tek kaynak kullanıldığında ters yön otomatik türetiliyor

Bir yazının İngilizce slug'ı değiştiğinde ters tabloyu güncellemeyi atlarsanız şu olur: Türkçe sayfa İngilizceyi gösteriyor, İngilizce sayfa Türkçeyi göstermiyor. Tek yönlü hreflang geçersizdir. Google karşılıklı işaret etmeyen bağlantıyı yok sayıyor ve iki sayfa birbirinden habersiz kalıyor.

Çözüm, ikinci sözlüğü silmek oldu:

/**
 * Türkçe slug -> diğer dillerdeki slug için TEK kaynak.
 *
 * Ters yönler (EN/DE -> TR) bu tablodan otomatik türetilir; ikinci bir
 * sözlük tutulmaz. Eskiden iki ayrı sözlük elle senkronize ediliyordu ve
 * biri güncellenmeyi unutulduğunda hreflang sessizce kayboluyordu.
 */
const explicitEnglishPairs: Record<string, string> = {
  'agent-skill-nedir-ise-yarayan-yetenek-nasil-yazilir':
    'what-is-an-agent-skill-how-to-write-useful-skills-en',
  // ...
};

Kural şu: Türkçe slug kaynak, diğerleri türev. Ters yön kodda hesaplanıyor. Böylece senkronize edilecek ikinci bir yer kalmıyor — çünkü senkronizasyon gerektiren her şey er ya da geç bozuluyor.

İkinci bir kolaylık: İngilizce tarafta varsayılan kural <türkçe-slug>-en. Kuraldan sapmayan yazılar tabloya hiç yazılmıyor. Almanca slug'lar SEO için Almanca kelimelerden oluştuğu için hepsi açıkça yazılıyor.

Slug'ı Türkçe tutmanın bedeli ve kazancı

Almanca yazının slug'ı neden was-ist-ein-agent-skill-... da agent-skill-nedir-...-de değil? Çünkü slug, arama sonucunda görünen ve tıklanma oranını etkileyen bir sinyal. Almanca arayan kullanıcıya Türkçe kelimelerden oluşan bir adres göstermek hem garip duruyor hem de terim eşleşmesini kaybediyor.

Bedeli: her Almanca slug elle yazılmak zorunda. Kazancı: her dilin kendi kelimeleriyle sıralanması. Bu takas, 400 yazıda fazlasıyla kârlı çıktı.

SSS bölümünü iki kez yazmamak

Arama sonucundaki açılır soru-cevap kutusuna aday olmak için sayfada FAQPage işaretlemesi gerekiyor. Klasik yöntem, soruları hem yazının içine hem de ayrı bir JSON-LD bloğuna yazmak. Yani aynı metni iki yerde tutmak.

İki yerde tutulan her şey gibi bu da ayrışıyor: yazıdaki cevabı düzeltiyorsunuz, işaretlemedeki eski kalıyor.

Burada tek kaynak markdown'ın kendisi. Yazının sonundaki "Sık Sorulan Sorular" bölümü ayrıştırılıp FAQPage şeması otomatik üretiliyor:

/** Üç dildeki SSS başlıkları. Parantezli varyantlar da ("FAQ (…)") yakalanır. */
const FAQ_HEADING =
  /^#{2,3}\s*.*(sık(ça)?\s+sorulan\s+sorular|frequently\s+asked\s+questions|häufig\s+gestellte\s+fragen|\bfaq\b).*$/i;

Yazar yalnızca yazıyı yazıyor; işaretleme üretimin yan ürünü oluyor. Üç dilin başlıkları tek düzenli ifadede toplandığı için Almanca yazı da aynı yoldan geçiyor.

Kategori sayfaları: filtre ile hub arasındaki fark

Başlangıçta kategoriler yalnızca blog listesindeki istemci tarafı filtrede yaşıyordu: ?category=flutter. Kullanıcı için yeterli, arama motoru için hiçbir şey. Google ?category= parametresini ayrı bir sayfa olarak görmüyor; yazı sayfasındaki kategori etiketi de hiçbir yere bağlanmıyordu.

Her kategori artık statik bir hub sayfası:

/tr/blog/kategori/flutter/
/en/blog/category/flutter/
/de/blog/kategorie/flutter/

Üç değişiklik birden geldi: kategori adı her dilde o dilin kelimesi oldu, yazı sayfasına kırıntı yolu (breadcrumb) eklendi, ve her hub kendi diline ait yazıları kendi başlığı ve giriş metniyle topladı.

Bunun SEO'daki adı topic cluster: dağınık yazılar yerine, bir konuyu toplayan sayfa ve ona bağlanan yazılar. Dağınık yazılar birbirine otorite aktaramıyor.

Görselleri üç dilde üretmek — ve CLS'i öldürmek

Yazılardaki diyagramlar üç dilde. Bunları elle çizmek üç kere aynı koordinatı yazmak demekti; biri düzeltilip diğeri unutulduğunda kimse fark etmezdi. Bu yüzden diyagramlar tek bir üreticiden çıkıyor: geometri ortak, etiketler dile göre.

SVG seçilmesinin sebebi de sadece dosya boyutu değil: metin SVG içinde gerçek metin olarak durduğu için hem arama motoru hem ekran okuyucu okuyabiliyor.

Asıl kritik ayrıntı ise şu: markdown'ın ![]() yazımında görsel boyutu yeri yok. Boyutsuz bir görsel yüklenene kadar sıfır yükseklik kaplıyor, yüklendiğinde metni aşağı itiyor. Bu sıçrama Core Web Vitals'ta CLS olarak ölçülüyor ve sıralamayı doğrudan etkiliyor.

Çözüm, üretim anında bir boyut tablosu yazmak:

export const diagramSizes: Record<string, { width: number; height: number }> = {
  '/assets/diyagram/harness-turu.svg': { width: 740, height: 412 },
  // ...
};

Markdown işlenirken bu tablo okunup <img> etiketine width ve height yazılıyor. Tablo üretim anında oluştuğu için render sırasında dosya okumaya gerek kalmıyor.

Bir yıllık önbellek: sessizce bozan ayrıntı

En öğretici hata buydu ve canlıda yaşandı.

Sitedeki ikonlar Material Symbols fontundan geliyor ama fontun tamamı değil, yalnızca kullanılan ~95 glif alt kümeleniyor — 10 KB'lık bir dosya. Font dosyası immutable ve bir yıllık max-age ile sunuluyor, ki her sayfada yeniden indirilmesin.

Sorun şurada: alt küme güncellenip yeni bir ikon eklendiğinde dosya adı aynı kalıyor. Daha önce siteye girmiş bir ziyaretçinin tarayıcısı eski fontu bir yıl boyunca kullanmaya devam ediyor ve yeni ikonlar onda ham metin olarak görünüyor. Sizde her şey düzgün, ziyaretçide bozuk.

Çözüm, font adresine içerik özetinden türeyen bir sürüm damgası basmak:

/fonts/material-symbols-outlined.woff2?v=8097db93

Alt küme değiştiğinde damga değişiyor, tarayıcı yeni dosyayı indiriyor. Damga değişmediğinde bir yıllık önbellek avantajı korunuyor.

Buradaki genel ders şu: agresif önbellek, dosya adı değişmeyen her varlık için bir tuzaktır.

Statik dışa aktarımda lang özniteliği

Son ayrıntı: her sayfanın <html lang="..."> özniteliği. Almanca bir sayfanın lang="tr" ile çıkması, hreflang doğru olsa bile karışık sinyal veriyor.

Derleme sonrası bir betik bunu bütün yerelleştirilmiş sayfalarda doğruluyor ve gerekiyorsa düzeltiyor:

Verified HTML language for 361 localized pages (361 corrected).

Bu satırı her derlemede görmek, "acaba bozuldu mu" sorusunu ortadan kaldırıyor. Doğrulanabilir olmayan hiçbir SEO kararı kalıcı değil — çünkü ne zaman bozulduğunu göremezsiniz.

Çıkarılacak beş ders

Mimarinin ayrıntıları bu siteye özel, ama kararların mantığı değil:

  1. Tek kaynak tut. Senkronize edilmesi gereken her ikinci kopya, er ya da geç ayrışıyor.
  2. İşaretlemeyi içerikten türet. SSS'yi iki yerde yazmak, ikisinin farklı olmasıyla bitiyor.
  3. Parametre sayfa değildir. Ayrı sıralanmasını istediğiniz her şeyin ayrı bir adresi olmalı.
  4. Görsele boyut yaz. CLS, düzeltilmesi en ucuz ve en çok ihmal edilen Core Web Vitals metriği.
  5. Önbelleklediğin her şeyi sürümle. Adı değişmeyen bir dosyayı bir yıl önbelleğe almak, ziyaretçinizi eski sürümde bırakmak demek.

Beşi de "bir kere kur, unut" türünden değil; her biri bir hatadan sonra eklendi. Teknik SEO'nun gerçek hâli de bu: önden tasarlanmıyor, bozuldukça öğreniliyor.

Kod örnekleri bu sitenin kaynağından; sürümler ve dosya yolları zamanla değişebilir.

Sık Sorulan Sorular

hreflang nedir, neden gerekli?

hreflang, aynı içeriğin farklı dillerdeki sürümlerini birbirine bağlayan bir işarettir. Doğru kurulduğunda arama motoru kullanıcıya diline uygun sürümü gösterir ve iki sayfayı birbirinin kopyası saymaz. Eksik olduğunda sayfalar birbirinden habersiz kalır ve aynı konuda kendi kendinizle yarışırsınız.

hreflang neden sessizce bozuluyor?

Çünkü bozulduğunda hata vermez. En yaygın sebep, çeviri eşleşmelerinin iki ayrı sözlükte tutulup birinin güncellenmeyi unutulmasıdır: sayfa A sayfa B'yi gösterir, B ise A'yı göstermez. Karşılıklı işaret etmeyen hreflang geçersiz sayılır. Çözüm tek kaynak tutup ters yönü koddan türetmek.

Çeviri sayfalarının slug'ı çevrilmeli mi?

Evet. Slug arama sonucunda görünen ve tıklanma oranını etkileyen bir sinyaldir; Almanca arayan kullanıcıya Türkçe kelimelerden oluşan bir adres göstermek hem terim eşleşmesini kaybettirir hem de güven vermez. Bedeli her slug'ı elle yazmaktır, ki bu bedel fazlasıyla karşılığını verir.

FAQPage şeması nasıl üretilmeli?

Tek kaynaktan. Soruları hem yazının içine hem ayrı bir JSON-LD bloğuna yazmak, iki kopyanın zamanla ayrışmasıyla biter. Bu sitede yazının sonundaki "Sık Sorulan Sorular" bölümü ayrıştırılıp şema otomatik üretiliyor; yazar yalnızca yazıyı yazıyor.

Blog kategorileri için ayrı sayfa şart mı?

Ayrı sıralanmasını istiyorsanız şart. ?category=flutter gibi bir istemci tarafı filtre kullanıcı için yeterli olsa da arama motoru bunu ayrı sayfa saymaz. Her kategoriye kendi adresi, kendi başlığı ve giriş metni verildiğinde dağınık yazılar birbirine otorite aktarabilen bir kümeye dönüşüyor.

Görsellere neden width ve height yazmak gerekiyor?

Boyutu belirtilmemiş bir görsel yüklenene kadar yer kaplamaz, yüklendiğinde metni aşağı iter. Bu sıçrama Core Web Vitals'ta CLS olarak ölçülür ve sıralamayı etkiler. Markdown'ın ![]() yazımında boyut yeri olmadığı için, boyutları üretim anında bir tabloya yazıp işleme sırasında <img> etiketine eklemek gerekiyor.

Yorumlar