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:
// 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.
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 ö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.
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;
}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.