Next.js'te hreflang ve Çok Dilli SEO'yu Doğru Kurmak
Çok dilli bir siteyi yayına aldığınızda Google'a iki şeyi net anlatmanız gerekir: her sayfanın asıl adresi hangisi (canonical) ve bu sayfanın diğer dillerdeki karşılıkları nerede (hreflang). Bunlar eksik veya yanlış olduğunda Google sayfaları birbirinin kopyası sanar, yanlış dildeki sürümü arama sonuçlarında gösterir ya da hiçbirini indekslemez.
Bu yazıda Next.js App Router ve next-intl kullanan bir sitede bu iki sinyali doğru kurmayı, ve pratikte en sık karşılaşılan üç hatayı anlatıyorum.
hreflang'i kök layout'ta tanımlamak neden yanlış?
En yaygın hata, alternates alanını kök layout'un generateMetadata fonksiyonunda tanımlamak:
// ❌ Bunu yapmayın
export async function generateMetadata() {
return {
alternates: {
languages: {
tr: "https://example.com/tr",
en: "https://example.com/en",
},
},
};
}Bu tanım layout'un altındaki her sayfaya miras kalır. Sonuç: /en/about sayfası da, /tr/blog/bir-yazi sayfası da hreflang olarak ana sayfayı gösterir. Google'a "bu sayfanın İngilizcesi ana sayfadır" demiş olursunuz, ki bu yanlıştır.
Doğrusu, her sayfanın kendi generateMetadata fonksiyonunda kendi alternatiflerini üretmesidir.
Yerelleştirilmiş yollar neden tek kaynaktan üretilmeli?
next-intl ile yolları çevirebilirsiniz — Türkçe /hakkimda, İngilizce /about olabilir:
// i18n/routing.ts
export const routing = defineRouting({
locales: ["tr", "en"],
defaultLocale: "tr",
pathnames: {
"/hakkimda": { tr: "/hakkimda", en: "/about" },
"/blog/[slug]": { tr: "/blog/[slug]", en: "/blog/[slug]" },
},
});Burada tehlike şu: sitemap veya canonical üretirken bu eşlemeyi ikinci kez elle yazmak. Yani sitemap.ts içinde ["hakkimda", "iletisim"] gibi bir dizi tutmak. İki kaynak zamanla ayrışır ve sitemap gerçekte var olmayan adresleri Google'a bildirmeye başlar — her biri yönlendirmeye düşer, tarama bütçesi boşa gider.
Çözüm, next-intl'in getPathname fonksiyonunu tek kaynak olarak kullanmak:
// i18n/navigation.ts
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);Artık URL üreten her yer aynı tanımdan besleniyor:
// lib/seo.ts
export function absoluteUrl(href: Href, locale: string) {
return `${SITE_URL}${getPathname({ href, locale })}`;
}
export function localeAlternates(href: Href, locale: string) {
return {
canonical: absoluteUrl(href, locale),
languages: Object.fromEntries(
routing.locales.map((l) => [l, absoluteUrl(href, l)])
),
};
}Sayfa tarafında kullanımı tek satır:
// app/[locale]/hakkimda/page.tsx
export async function generateMetadata({ params }) {
const { locale } = await params;
return {
title: "Hakkımda",
alternates: localeAlternates("/hakkimda", locale),
};
}Çıktı, sayfaya özel ve doğru:
<link rel="canonical" href="https://example.com/en/about" />
<link rel="alternate" hreflang="tr" href="https://example.com/tr/hakkimda" />
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />Sitemap neden yerelleştirilmiş yollar içermeli?
Aynı absoluteUrl yardımcısı sitemap'te de kullanılmalı. App Router'ın sitemap.ts dosyası alternates.languages alanını destekler ve bunu Google'ın önerdiği xhtml:link formatında üretir (Next.js sitemap dokümanında var). Genel hâli şöyle:
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [];
for (const locale of routing.locales) {
for (const href of staticHrefs) {
entries.push({
url: absoluteUrl(href, locale),
lastModified: new Date(),
alternates: {
languages: Object.fromEntries(
routing.locales.map((l) => [l, absoluteUrl(href, l)])
),
},
});
}
}
return entries;
}Sitemap'inizi yayına aldıktan sonra basit bir doğrulama yapın — içindeki her adres 200 dönmeli, hiçbiri yönlendirme olmamalı:
curl -s https://example.com/sitemap.xml \
| grep -o '<loc>[^<]*</loc>' | sed 's/<[^>]*>//g' \
| while read u; do
code=$(curl -s -o /dev/null -w '%{http_code}' "$u")
[ "$code" != "200" ] && echo "$code $u"
doneÇıktı boşsa sitemap temizdir.
Bu sitenin kendi sitemap'inden iki not. Burada sitemap.ts yok; aynı xhtml:link XML'ini elle yazan bir route handler var (app/sitemap.xml/route.ts). Metadata konvansiyonu yerine handler'ı bilinçli olarak seçtim. Yine aynı absoluteUrl yardımcısından besleniyor, yani tek kaynak kuralı bozulmadı. Bu yolu seçerseniz: yalnızca yerel dosya okuyan bir handler route seviyesindeki revalidate ile yenilenmez; bu sitede force-dynamic işaretli.
İkinci not lastmod. İlk sürüm her statik sayfaya new Date() yazıyordu; yani her adres istek anında değişmiş gibi görünüyordu. Google lastmod'u yalnızca tutarlı ve doğrulanabilir biçimde doğruysa kullanıyor; hep "şimdi" diyen değer tüm sinyali güvenilmez kılar. Artık sitemap lastmod'u yalnızca içeriği gerçekten değişen yerlere yazıyor (liste sayfaları en yeni öğenin tarihini, yazılar kendi tarihini alıyor), kalanlarda hiç yazmıyor. Uydurmaktansa yazmamak daha iyi.
TR ve EN slug'ları farklıyken hreflang nasıl eşleştirilir?
Statik sayfalarda yol eşlemesi routing tanımından gelir. Ama blog yazılarında slug'lar genelde tamamen farklıdır:
/tr/blog/cok-dilli-seo-kurulumu/en/blog/multilingual-seo-setup
Bu ikisini otomatik eşleştirmenin bir yolu yok — çünkü aralarında ortak bir anahtar yok. Çözüm, frontmatter'a bir eşleştirme anahtarı eklemek:
---
title: "Çok Dilli SEO Kurulumu"
locale: "tr"
translationKey: "multilingual-seo-setup"
---İki dildeki dosya aynı translationKey değerini paylaşır. Karşılığı bulan yardımcı basit:
export function getTranslationSlug(post, targetLocale: string) {
const key = post.frontmatter.translationKey;
if (!key) return null;
return (
getPosts(targetLocale).find(
(p) => p.frontmatter.translationKey === key
)?.slug ?? null
);
}Anahtar tanımlı değilse null döner ve o dil için hreflang hiç yazılmaz. Bu sitede bir yazının languages haritası metadata'ya yalnızca birden fazla girdisi varsa eklenir; çevirisi olmayan yazı sadece canonical alır. Bu önemli: eksik hreflang, yanlış hreflang'den iyidir. Yanlış eşleştirme Google'ı hatalı sayfaya yönlendirir, eksik olan ise sadece bir sinyali kaçırır.
next-intl'in kendi alternatif linklerini neden kapattım?
next-intl middleware'i, pathnames tanımından üretilmiş rel="alternate" hreflang girdileriyle Link yanıt başlıkları da gönderir. Blog yazılarında bu yanlış çalışır: /blog/[slug] iki dilde de aynı desendir, bu yüzden başlık aynı slug'ı diğer dile taşır. TR ve EN slug'ları farklıysa sonuç, 404 dönen bir adrese hreflang linkidir.
Her sayfa zaten generateMetadata içinde kendi doğru alternates.languages değerini kurduğundan çözüm başlığı kapatmak:
export const routing = defineRouting({
locales: ["tr", "en"],
defaultLocale: "tr",
alternateLinks: false,
pathnames: { /* ... */ },
});Çıkarılacak ders: hreflang Google'a üç kanaldan ulaşabilir (HTML etiketleri, sitemap, HTTP başlıkları) ve üçü birbirini tutmalı. Unuttuğunuz otomatik bir kanal, özenle yazdıklarınızla çelişebilir.
Dil değiştirici blog yazılarında neden 404 verebilir?
Kolayca gözden kaçan bir nokta: kullanıcı bir blog yazısını okurken dil değiştirdiğinde ne oluyor? Naif bir dil değiştirici mevcut slug'ı olduğu gibi taşır ve hedef dilde var olmayan bir adrese gider — 404.
Dil değiştirici genelde bir client component olduğu için içerik dosyalarını okuyamaz. Eşleme haritasını sunucuda hesaplayıp prop olarak geçmek gerekir:
// layout.tsx (server component)
<Header blogSlugMap={getBlogSlugMap(routing.locales)} />// locale-switcher.tsx (client component)
function paramsFor(targetLocale: string) {
const slug = params?.slug;
if (pathname !== "/blog/[slug]" || typeof slug !== "string") return params;
const translated = blogSlugMap?.[targetLocale]?.[slug];
return translated ? { ...params, slug: translated } : params;
}Harita sunucuda aynı translationKey eşleşmesinden, her kaynak-hedef dil çifti için dönülerek kurulur; karşılığı olmayan yazı atlanır ve mevcut slug'ını korur. Haritayı hedef dile göre indeksleyin, kaynak dile göre değil. İki dille çalışırken ikisi de aynı sonucu verir, ama üçüncü bir dil eklediğinizde kaynak dile göre indekslenmiş harita yanlış slug'a yönlendirir.
Yayın sonrası doğrulama
Deploy ettikten sonra üç şeyi kontrol edin:
# Alt sayfada hreflang gerçekten o sayfaya mı işaret ediyor?
curl -s https://example.com/en/about | grep -E 'rel="(alternate|canonical)"'
# Sitemap'te yerelleştirilmiş yollar var mı?
curl -s https://example.com/sitemap.xml | grep '/en/'Ardından Google Search Console'a sitemap'i gönderin ve birkaç sayfa için URL Denetimi çalıştırın. "Kullanıcı tarafından beyan edilen standart URL" satırı sizin canonical'ınızla eşleşmelidir.
Özet
Çok dilli SEO'da işlerin ters gitmesinin nedeni genelde egzotik bir Google kuralı değil, URL üretiminin birden fazla yerde tekrarlanması oluyor. Yol tanımını tek bir kaynakta tutup canonical, hreflang, sitemap ve dil değiştiriciyi hep o kaynaktan beslerseniz, yeni sayfa eklediğinizde hepsi kendiliğinden doğru kalır.