İçeriğe geç
codebiy
← Yazı masasına dön

Next.js'te tek URL, iki dil: arama motorlarının göremediği yarı

Sitemiz iki dili tek adreste tutuyor, hangisini göstereceğini çereze göre seçiyordu. Crawler'lar bu yüzden hep İngilizcesini gördü. Dili URL'ye taşıyınca bu düzeldi, bütün adresleri listeleyince de dört hata daha çıktı.

Kasım 2025'ten Ağustos 2026'ya kadar codebiy.com'daki her sayfanın tek adresi vardı, o adresin arkasında da iki dil duruyordu. Hangisinin okunacağı çereze bağlıydı. Site hem İngilizce hem Türkçe yazılmıştı ve dil düğmesine hiç basmamış ziyaretçi İngilizcesini görüyordu. Crawler'lar da her gelişlerinde aynısını görüyordu: örneğin Google'ın render servisi çerezleri sayfa yüklemeleri arasında siliyor.

Türkçe yarı yazılmıştı, bakımı da yapılıyordu ama onu döndüren URL yoktu. Bu yazıda bunun nasıl olduğunu, yerine ne koyduğumuzu ve bütün adresleri tek tek yazmak gerekince başka nelerin ortaya çıktığını anlatıyoruz. Site Next.js 16 ve next-intl 4 ile çalışıyor.

Eski düzende dil seçimi

URL yolunda dil kodu yoktu, middleware de yoktu. Dil, next-intl'in istek yapılandırmasında çerez okunarak seçiliyordu:

export default getRequestConfig(async () => {
  const cookieStore = await cookies();
  const localeCookie = cookieStore.get("locale")?.value;

  const locale =
    localeCookie && locales.includes(localeCookie as Locale)
      ? localeCookie
      : defaultLocale;

  return {
    locale,
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});

defaultLocale değeri "en" idi. Dil düğmesi bir server action çağırıyor, seçimi bu çereze bir yıllığına yazıyor ve sayfayı yeniliyordu. Adres aynı, metin başka.

İnsan için bu düzen işler. TR'ye bir kez basarsınız, site bir yıl Türkçe kalır. İçeriden bakınca hiçbir şeyin bozuk görünmemesinin sebebi de bu.

Şimdi aynı isteği crawler gibi, çerezsiz yapalım. Yanıt, sayfanın tek adresinde, <html lang="en"> ile İngilizce gelir. Türkçe metni döndüren çerezsiz bir istek yok. Dolayısıyla dizine eklenecek sayfa, hreflang etiketinin gösterebileceği adres, sitemap'e yazılacak satır da yok. İki dilli içeriği bu şekilde sunan yirmi sekiz URL vardı.

İllüstrasyon: tek direkte iki kanatlı tabela, bir kanadı krem, öteki kırmızı; kurmalı mekanik örümcek krem kanadın önünde duruyor. Tek adres, iki dil: çerezsiz gelen crawler hep İngilizcesini gördü.

Bu düzen iki küçük sorun daha getiriyordu. Sitenin varsayılan başlığı ve açıklaması kök layout'ta tek parça, sabit bir İngilizce bloktu, yani Türkçe sayfaların metadata'sı İngilizceydi. Ayrıca cookies() Next.js'te istek anında çalışan bir API: dili ona bağlı olan sayfa önceden üretilemez.

Çözüm: dil kodunu URL'ye taşımak

Google'ın tavsiyesi çerez ya da tarayıcı ayarı kullanmak değil, bir sayfanın her dildeki sürümüne ayrı URL vermek. Ağustos 2026'da bütün route'ları app/[locale]/ altına taşıdık, gerisini next-intl'in middleware'i hallediyor. Next.js 16'dan beri bu dosyanın adı proxy.ts:

export default createMiddleware({
  locales,
  defaultLocale,
  localePrefix: "always",
  localeDetection: true,
  localeCookie: false,
  alternateLinks: false,
});

localePrefix: "always" ayarıyla bir sayfanın adresleri yalnızca /en/… ve /tr/… oluyor. Hangi dile ait olduğunu söylemeyen, ön eksiz bir sürüm yok. Amaç da bu: her sayfanın her dilde tek bir adresi olur, hreflang'in de göstereceği bir yer bulunur. Son iki seçeneği Ekim 2026'da ekledik, aşağıda anlatıyoruz.

Taşımanın kendi hatası da oldu. Middleware'in matcher'ı alışıldık kalıptı: içinde nokta olmayan her yol. Next'in ürettiği metadata route'larının dosya uzantısı yok. Aynı gün öğleden sonra, üretilen bir Apple ikonu ekleyince /apple-icon, var olmayan /en/apple-icon adresine yönlendirildi.

Düzeltme aynı değişikliğe girdi, yani hata hiç deploy edilmedi. Metadata route'larını artık adlarıyla hariç tutuyoruz:

matcher: ["/((?!api|_next|apple-icon|icon|opengraph-image|.*\\..*).*)"],

Listede bugün bir madde daha var: bir OAuth redirect URI'si, yani üçüncü taraf oturum açma akışının kullanıcıyı geri gönderdiği adres. Bu adres karşı tarafta dil kodu olmadan kayıtlı. /en/… adresine yapılacak yönlendirme, kullanıcıyı kayıtlı olandan başka bir adrese götürürdü.

Her dilin kendi adresi olunca her sayfa, metadata'sındaki alternates alanıyla kendini tanıtabiliyor. Bir blog yazısınınki şöyle:

alternates: {
  canonical: `/${locale}/blog/${slug}`,
  languages: {
    en: `/en/blog/${slug}`,
    tr: `/tr/blog/${slug}`,
    "x-default": `/en/blog/${slug}`,
  },
  types: { "application/rss+xml": `/${locale}/blog/feed.xml` },
},

Son satır, Ekim 2026'dan beri her dilin kendi RSS feed'i olduğunu duyuruyor. Sitemap her yolu her dil için bir kez listeliyor ve her satır iki sürümün adresini de veriyor:

return locales.flatMap((locale) =>
  entries.map(({ path, lastModified }) => ({
    url: `${site.url}/${locale}${path}`,
    ...(lastModified ? { lastModified } : {}),
    // changeFrequency and priority omitted
    alternates: {
      languages: Object.fromEntries(
        locales.map((l) => [l, `${site.url}/${l}${path}`]),
      ),
    },
  })),
);

Dil kodu URL yoluna girince gerisi kendiliğinden geldi. Başlık ve açıklamalar artık gövde metniyle aynı mesaj dosyalarından okunuyor, <html lang> URL'deki dil koduyla eşleşiyor ve her sayfa build sırasında her dil için bir kez üretiliyor. Dil düğmesi de sıradan bir bağlantıya dönüştü: yolun ilk parçasını değiştirip o adrese gidiyor, query string'i ve hash'i koruyor.

Dilin tahmin edildiği tek yer

Tahmin yürütmeye izin verdiğimiz tek yer, dil kodu olmayan adres. Orada middleware tarayıcının Accept-Language header'ını okuyup yönlendiriyor. 9 Ekim 2026'da canlı siteye çerezsiz istek attık:

/blog isteği Yanıt
Accept-Language: tr 307, /tr/blog
Accept-Language: de 307, /en/blog
Accept-Language: ja 307, /en/blog
header yok 307, /en/blog

Google'ın crawler'ı Accept-Language header'ı göndermiyor, yani son satırdaki yanıtı alıyor.

Dil kodu olan adrese ise dokunmuyoruz: /en/blog Türkçe tarayıcıya da 200 dönüyor, /tr/blog İngilizce tarayıcıya da.

Artık işin içinde çerez yok. Ziyaretçi, tarayıcısının dilinden farklı bir dil açtığında next-intl varsayılan olarak bu seçimi oturum çerezine yazıyor ve dil kodu olmayan adreslerde Accept-Language'den önce ona bakıyor. Sitemiz Ekim 2026'ya kadar böyle çalıştı. Çerez yalnızca /blog adresinin nereye yönlendirileceğini belirliyordu: çerezi Türkçeye ayarlayıp /en/blog adresine istek attık, yönlendirme değil 200 aldık. Sitedeki her bağlantıda zaten dil kodu var, biz de çerezi localeCookie: false ile kapattık.

Arama motorları için varsayılan sürüm sayfaların kendisinde bildiriliyor. Blog listesinde ve yazılarda, yukarıdaki kodda görülen x-default satırı var. Bu satıra göre, kendi sayfası olmayan her dil için geçerli sürüm İngilizce. Öteki sayfalar yalnızca iki dilini bildiriyor.

Ekim 2026'ya kadar bir yerde daha bildiriliyordu. Varsayılan ayarda next-intl'in middleware'i bu karşılıkları Link header'ı olarak da gönderiyor. Orada x-default, dil kodu olmayan adres.

Blog sayfalarına 9 Ekim 2026'da kendi x-default satırını ekleyince etiketle header farklı adres gösterdi: sayfada /en/blog, header'da /blog. Google'ın yerelleştirilmiş sürümler sayfasına göre etiketlerle header'lar eşdeğer. Biz etiketleri yerinde bıraktık, header'ı alternateLinks: false ile kapattık.

Adresleri listelerken çıkan öteki hatalar

Her sayfaya her dilde adres vermek, sitenin Eylül 2025'ten beri biriktirdiği bütün adresleri listelemek demekti. Dille hiç ilgisi olmayan dört hata çıktı.

Tek uygulama için iki sayfa. iOS için rüya günlüğümüz Dusora'nın adı o zaman hâlâ Rüya Defteri'ydi. Katalogda detay sayfası, bir de kendi küçük sitesi vardı. İkisi de sitemap'teydi ve aynı sorgu için birbiriyle yarışıyordu. Üstelik küçük sitenin sayfalarına sitemap dışında hiçbir yerden bağlantı verilmiyordu. Artık tek sayfa var, eski adresler de kalıcı yönlendirmeyle yanıt veriyor:

{
  source: "/:locale(en|tr)/platforms/ruya-defteri",
  destination: "/:locale/dusora",
  permanent: true,
},

Her şeyin bugün değiştiğini söyleyen sitemap. Eski sitemap ana sayfaya, blog listesine, bütün ürün ve hizmet sayfalarına new Date() basıyordu. Her deploy'dan sonra crawler'lar kataloğun tamamını az önce düzenlenmiş görüyordu. Hep şimdiyi gösteren bir tarih bilgi taşımaz. lastModified artık yalnızca gerçek bir tarihin olduğu yerde yazılıyor. Bugün için bu, yazılar demek.

Canlıya çıkmış yer tutucu. Sitenin metadata'sında verification: { google: "verification-code-here" } duruyordu. Düpedüz bir yer tutucu, HTML'nin içinde canlıdaydı. Kaldırdık, oraya koyacak gerçek bir token olana kadar da boş kalacak.

Görseli olmayan büyük görsel kartı. Twitter kart tipi summary_large_image idi ve sitenin hiçbir yerinde og:image yoktu. Paylaşılan her bağlantı boş kart olarak görünüyordu. Yeniden yazımda her dil için üretilen bir görsel ekledik.

9 Ekim 2026'da canlı sitede bu görsele istek attık ve 502 aldık. Yazı sayfalarının hiç görsel bildirmediğini de o gün gördük. İkisi de düzeltildi, artık her yazının kendi kartı var. Bunun nasıl gözden kaçtığını yapay zekâ agent'larının yazdığı front-end'i doğrulama üzerine yazımızda anlattık.

lang, hreflang ve mesaj dosyası kontrolleri

Bunların hiçbirini yeniden bozmak zor değil. alternates bloğu olmayan yeni bir sayfa ya da yalnızca tek dil dosyasına eklenmiş bir mesaj anahtarı, eski sorunun bir parçasını geri getirir. İkisi de build'i kırmaz.

Doğrulama script'i kendisine verilen her route'u iki dilde, gerçek tarayıcıda açıyor. Yanıt 200 değilse, <html lang> yoldaki dil koduyla eşleşmiyorsa ya da hreflang'lerden biri eksikse başarısız oluyor:

if (!meta.title) fail(`title ${target}`, "empty");
else if (meta.lang !== locale)
  fail(`lang ${target}`, `got "${meta.lang}"`);
else if (!meta.alts.includes("en") || !meta.alts.includes("tr"))
  fail(`hreflang ${target}`, meta.alts.join(",") || "none");
else ok(`meta ${target}`);

İkinci bir script iki mesaj dosyasını anahtar anahtar karşılaştırıyor. Bir dilde olup diğerinde olmayan anahtar ya da bir dilde daha uzun olan liste varsa başarısız oluyor.

İkisi de kendiliğinden çalışmıyor. Mesaj karşılaştırması npm test ile çalışıyor, onu push'tan önce çalıştırıyoruz. Tarayıcı kontrolü çalışan bir build istiyor ve elle başlatılıyor: ya biz başlatıyoruz ya da o işi yapan agent. Deploy ikisini de çalıştırmıyor.

Yukarıdaki script en son 8 Ağustos 2026'da, ana sayfa için iki dilde rapor yazdı. Durum kodunu, canonical adresi ve hreflang'i tarayıcıda en son 9 Ekim 2026'da, şimdiki yazılar yayımlanırken ayrı bir çalıştırmayla kontrol ettik: ana sayfa, blog listesi ve bütün yazılar, iki dilde, canlı sitede.

Crawler'ın bugün aldığı yanıt

Dizine kaç sayfanın eklendiğini gösteremiyoruz. 9 Ekim 2026'da dizine eklenmiş sayfalar için kullanılabilir bir sayı alamadık, değişiklikten öncesine ait arama verisi de tutmadık. Bu yüzden değişikliğin sıralamaları ya da trafiği nasıl etkilediğini söylemeyeceğiz.

Gösterebildiğimiz şey, çerezsiz bir isteğin ne aldığı, yani yukarıdaki tablo. Ağustos 2026'dan önce böyle bir istek Türkçe yanıt alamıyordu. Şimdi /tr/blog, <html lang="tr"> ile yanıt veriyor, İngilizce karşılığını gösteriyor ve sitemap'te yer alıyor.

Çok dilli siteler için kontrol listesi

Crawler'ın ne aldığını iki istek gösterir. Adresi kendi sitenizinkiyle değiştirin:

# an address without a locale, as a Turkish browser
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' \
  -H 'Accept-Language: tr' https://codebiy.com/blog

# a page's own address, without cookies
curl -s https://codebiy.com/tr/blog | grep -o '<html[^>]*lang="[a-z]*"'

Bizim sitede ilki 307 https://codebiy.com/tr/blog, ikincisi <html lang="tr" yazıyor. Birkaç dakikalık iki kontrol daha:

  • Herhangi bir sayfada curl -sI çalıştırıp Link ve Set-Cookie header'larını okuyun. Middleware'iniz hreflang'i zaten orada gönderiyor olabilir.
  • Paylaşılan bir bağlantıyı açıp karta bakın, sonra görselin adresine ayrıca istek atın.

Düzeltmeler daha uzun sürer:

  • Her dile kendi URL'sini verin. Her sayfa canonical adresini ve hreflang karşılıklarını tek bir x-default ile birlikte bildirsin.
  • Dili yalnızca dil kodu olmayan adreslerde tahmin edin. İçinde dil kodu olan URL, tarayıcı diline göre asla yönlendirilmemeli.
  • Uzantısız metadata route'larını ve üçüncü tarafta kayıtlı OAuth redirect URI'lerini dil matcher'ının dışında tutun.
  • Sitemap'te iki dili de listeleyin, lastmod'u yalnızca doğruysa gönderin.
  • lang ve hreflang kontrollerini script'e koyun. Kimin, ne zaman çalıştıracağını da kararlaştırın.
OKUMAYA DEVAM ETDocker'da npm ci sağlam lockfile'ı reddetti: npm 10 ve npm 12 farkı ↗Next.js web sürecinde Remotion: beş hata ve Postgres kuyruğuna geçiş ↗