selupucin
Web Geliştirme

Next.js App Router ile Güçlü SEO Kurulumu

metadataBase'den kanonik kurguya, parçalı sitemap'ten JSON-LD ve OG görseline: App Router'da sağlam bir SEO temelini adım adım nasıl kurduğumu anlatıyorum.

Deniz Selupuçin7 dk okuma

App Router SEO'yu neden yeniden düşündürüyor

App Router ile birlikte SEO işi bir eklenti ayarı olmaktan çıkıp doğrudan kodun bir parçası hâline geldi. Bu ilk bakışta daha çok emek gibi görünür; ama karşılığında elde ettiğin şey tam kontroldür. Kanonik adres nasıl üretilecek, sitemap kaç parçaya bölünecek, hangi sayfa statik hangisi dinamik render edilecek — hepsi senin kararın.

Bu yazıda kurumsal ölçekte defalarca kurduğum SEO temelini anlatıyorum. Amacım her API'yi tek tek dökmek değil; hangi kararın neden alındığını ve nerede hata yapıldığını göstermek. Platform seçiminin kendisini tartıştığım yazı ayrı duruyor: Next.js mi WordPress mi?

Her şeyin temeli: metadataBase

En sık gördüğüm hata en baştaki hatadır. Kanonik adresleri ve sosyal medya görsellerini göreli yol olarak yazmak, staging ortamında canlı sitenin adresini, canlıda ise localhost adresini üretebilir. Sonuç: yanlış kanonik, yanlış paylaşım görseli, indekslenmeyen sayfalar.

Çözüm, mutlak adresin tek bir merkezden türetilmesidir.

// app/layout.tsx
import type { Metadata } from "next";

const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";

export const metadata: Metadata = {
  metadataBase: new URL(siteUrl),
  title: {
    default: "Marka — kısa vaat",
    template: "%s · Marka",
  },
  alternates: { canonical: "/" },
};

Buradaki template alanı önemli bir disiplin sağlar: alt sayfalarda marka ekini tekrar tekrar yazmazsın, merkezden eklenir. Bu sayede başlık uzunluğu kontrolden çıkmaz.

Aynı mutlak adres üretimini bir yardımcı fonksiyona alıp JSON-LD kimliklerinde de kullanmanı öneririm. İki ayrı yerde adres üreten kod, er ya da geç ayrışır.

generateMetadata ile sayfaya özel meta

Dinamik sayfalarda meta verisi veriden türetilir. App Router'da bunun yolu generateMetadata. Kritik nokta: bu fonksiyon içindeki veri isteği, sayfa bileşenindeki istekle aynı önbelleği paylaşır; yani veriyi iki kez çekmezsin.

// app/blog/[slug]/page.tsx
export async function generateMetadata(props) {
  const { slug } = await props.params;
  const post = await getPost(slug);
  if (!post) return { title: "Bulunamadı", robots: { index: false } };

  return {
    title: post.metaTitle || post.title,
    description: post.metaDescription || post.excerpt,
    alternates: { canonical: "/blog/" + slug },
    openGraph: {
      type: "article",
      title: post.metaTitle || post.title,
      publishedTime: post.publishedAt,
    },
  };
}

Bu yapıda dikkat edilecek üç şey var:

  • Öncelik sırası net olmalı. Editörün girdiği özel alan varsa o kazanır, yoksa varsayılan içerikten türetilir. Bu sırayı tek bir yardımcı fonksiyonda topla; her sayfada tekrar yazma.
  • Bulunamayan içerik indekslenmemeli. Veri yoksa hem 404 döndür hem indekslemeyi kapat.
  • Uzunluk sınırlarını kaynağında koru. Başlık ve açıklamayı yönetim panelinde sınırlamak, sonradan kesilen açıklamalarla uğraşmaktan iyidir.

Kanonik kurgu ve sayfalama tuzağı

Kanonik adres, aynı içeriğe birden çok yoldan ulaşılabildiğinde hangi adresin asıl olduğunu söyler. Kurumsal sitelerde kanonik hatalarının en sık kaynağı sayfalamadır.

Benim uyguladığım kural şu:

  • Liste sayfası kökte durur, sonraki sayfalar ayrı bir yol deseninde ilerler.
  • Sayfa numarası bir olan adres, kalıcı yönlendirme ile köke gider. Böylece aynı içerik iki adreste yaşamaz.
  • İkinci ve sonraki sayfalar kendi adresini kanonik gösterir. Hepsini köke kanonikleyerek "birleştirme" yapmak, o sayfalardaki içeriğin görünmez olmasına yol açar.

Aynı mantık filtre ve sıralama parametreleri için de geçerli: sonsuz kombinasyon üreten parametreler indekslenmemeli, kanonik temiz adresi göstermelidir. Konunun tamamı için kurumsal sitelerde teknik SEO kontrol listesi yazısına bakabilirsin.

robots.ts ve parçalı sitemap

App Router bu iki dosyayı da kodla üretmene izin veriyor; bu, statik dosya kopyalamaktan çok daha güvenli çünkü içerik veriden türer.

// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "";
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: ["/admin", "/api"] }],
    sitemap: siteUrl + "/sitemap.xml",
  };
}

Sitemap tarafında ölçek büyüdüğünde tek dosya yetmez. Bir sitemap dosyasının sınırı elli bin URL'dir ama pratikte çok daha erken bölmek gerekir: küçük ve tematik dosyalar hem daha hızlı işlenir hem hangi bölümün indekslendiğini Search Console'da ayrı ayrı görmeni sağlar.

// app/sitemap.ts
export async function generateSitemaps() {
  const parcaSayisi = await getSitemapChunkCount();
  return Array.from({ length: parcaSayisi }, (_, i) => ({ id: i }));
}

export default async function sitemap(props) {
  const { id } = props;
  const kayitlar = await getUrlsForChunk(id);
  return kayitlar.map((k) => ({
    url: k.url,
    lastModified: k.updatedAt,
    changeFrequency: "weekly",
    priority: k.priority,
  }));
}

Sitemap'e yalnızca indekslenmesini istediğin, 200 dönen ve kanonik olarak kendini gösteren adresleri koy. Yönlendirilen, engellenen ya da kanoniği başka sayfa olan adresleri koymak, tarama bütçesini boşa harcamaktan başka işe yaramaz.

Yapısal veri: JSON-LD

JSON-LD, sayfanın ne olduğunu arama motoruna açıkça söyler. App Router'da bunu sunucu bileşeni içinde basmak en temiz yoldur; istemciye ek JavaScript yükü binmez.

export default async function Page(props) {
  const { slug } = await props.params;
  const post = await getPost(slug);
  const schema = {
    "@context": "https://schema.org",
    "@type": "BlogPosting",
    "@id": absoluteUrl("/blog/" + slug),
    headline: post.title,
    datePublished: post.publishedAt,
    author: { "@type": "Person", name: post.authorName },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
      />
      <Article post={post} />
    </>
  );
}

Üç kural:

  • Kimlik alanı mutlak ve kararlı olmalı. Ortama göre değişen bir kimlik, staging verisinin canlı grafiğe karışmasına yol açar. Bu yüzden adres üretimini merkezî yardımcıdan al.
  • Yalnızca sayfada görünen bilgiyi işaretle. Sayfada olmayan bir puanı ya da bilgiyi şemaya koymak, zengin sonuç kaybına ve manuel işleme kadar gidebilir.
  • Şemaları birbirine bağla. Kuruluş, web sitesi, kırıntı navigasyonu ve içerik şemaları birbirine referans verdiğinde arama motoru sitenin yapısını daha net kurar.

OG görselini kod ile üretmek

Sosyal paylaşım görselini her içerik için elle hazırlamak sürdürülebilir değil. App Router'da görsel üreten bir rota tanımlayıp başlığı ve markayı dinamik basabilirsin. Böylece her yeni yazı, yayınlandığı an paylaşıma hazır bir görsele sahip olur.

Pratik notlar: boyutu 1200x630 tut, metni kısa ve büyük punto yaz, marka ögesini sabit bir konumda tekrar et ve görselin üretimini önbelleğe al. Uzun başlıklar için karakter sınırı koy; taşan metin görseli okunmaz hâle getirir. Font dosyasını rotaya açıkça yüklemeyi de unutma, aksi hâlde üretilen görselde yazı tipi sistem varsayılanına düşer.

Ayrıca kök düzeyde bir varsayılan görsel bulundur; içerik görseli üretilemezse paylaşım boş kalmasın. Görselin gerçekten çalıştığını doğrulamanın en hızlı yolu, adresini doğrudan tarayıcıda açıp bir de sosyal ağların paylaşım önizleme araçlarından geçirmektir. Bu adım atlandığında hata canlıda, ilk paylaşımda görülür.

Statik mi dinamik mi: render kararı

SEO açısından en önemli mimari karar budur. Basit bir çerçeve kullanıyorum:

  • Statik üretim: İçerik herkes için aynıysa ve sık değişmiyorsa. Hizmet sayfaları, sözlük, kurumsal sayfalar. En hızlı ve en ucuz seçenek.
  • Artımlı statik yenileme: İçerik değişiyor ama her istekte taze olması gerekmiyorsa. Blog, katalog, proje listeleri. Statik hızını korur, güncelliği yakalar.
  • Sunucu tarafı render: İçerik kişiye ya da isteğe özelse. Arama sonuçları, panel ekranları.
  • İstemci tarafı: Arama motorunun görmesi gerekmeyen etkileşimli parçalar için.

Buradaki ana tuzak, tek bir dikkatsiz çağrının tüm sayfayı dinamik hâle getirmesi. Çerez ya da başlık okuyan bir yardımcı fonksiyon, farkında olmadan statik üretimi kapatabilir. Yayın öncesi derleme çıktısına bakıp hangi rotanın statik hangisinin dinamik olduğunu doğrulamak beş dakikalık bir iştir ve çok şey kurtarır.

Bir diğer önemli nokta: içeriğin ilk HTML'de bulunması. Sunucu bileşenleri sayesinde metin doğrudan HTML'e gelir; içeriği yalnızca istemcide getiren bir kurgu, tarayıcı için ek bir risk yaratır.

Çok dilli kurulum

Birden çok dil varsa üç şeyi baştan doğru kurmak gerekir: her dil için ayrı ve kalıcı bir adres, karşılıklı dil alternatifi bildirimleri ve dil seçimine göre doğru kanonik.

Sonradan dil eklemek her zaman acı verir; bu yüzden tek dille yayına çıkacak olsan bile adres yapısını dil eklenebilecek şekilde kurmanı öneririm. Ancak yayınlanmayan dilin sayfalarını üretmemek de aynı derecede önemli: yarı çevrilmiş, boş ya da birebir kopya sayfalar hem tarama bütçesi yakar hem kalite algısını düşürür. Dil, ancak içeriği tamamlandığında açılmalı.

Pratikte bunu bir ortam değişkeniyle yönetiyorum: yayında olan diller listeleniyor, statik yol üretimi yalnızca bu listedeki diller için çalışıyor. Böylece hazır olmayan dilin tek bir sayfası bile derlemeye girmiyor, sitemap'e düşmüyor ve dil alternatifi bildirimlerinde görünmüyor. Dil hazır olduğunda tek bir değişiklik yeterli oluyor.

Yayına almadan önce

  • Kanonik adresler mutlak ve ortamdan bağımsız üretiliyor mu?
  • Sayfa bir adresi köke yönlendiriliyor, sonraki sayfalar kendini kanonikliyor mu?
  • Sitemap yalnızca indekslenebilir adresleri içeriyor mu, robots dosyası doğru mu?
  • Yapısal veri zengin sonuç testinden hatasız geçiyor mu?
  • Her sayfada tek bir birinci düzey başlık ve anlamlı bir başlık hiyerarşisi var mı?
  • Paylaşım görselleri gerçekten üretiliyor mu, varsayılan görsel duruyor mu?
  • Statik olması gereken rotalar derlemede gerçekten statik mi?
  • 404 sayfası doğru durum kodu dönüyor mu, yönlendirmeler kalıcı mı?

Özet

  • Mutlak adres üretimini tek merkeze al; kanonik ve yapısal veri kimlikleri oradan beslensin.
  • Meta verisini generateMetadata ile veriden türet, öncelik sırasını tek bir yardımcıda topla.
  • Sayfalamada kanonik kuralını baştan kur; sonradan düzeltmek trafik kaybettirir.
  • Sitemap'i parçalı üret, yalnızca indekslenebilir adresleri koy.
  • JSON-LD'yi sunucuda bas, yalnızca görünen bilgiyi işaretle.
  • Render stratejisini içeriğin doğasına göre seç ve derleme çıktısıyla doğrula.

Bu temel bir kez doğru kurulduğunda, sonrasında eklenen her yeni sayfa aynı disiplini otomatik devralır. Mevcut kurulumunu bu maddelerle karşılaştırmak ya da sıfırdan sağlam bir temel kurmak istersen web uygulaması geliştirme yaklaşımıma bakabilir ya da doğrudan bir proje konuşmak için yazabilirsin.

Devamı

İlgili yazılar

Web Geliştirme
9 dk okuma

KVKK Uyumlu Web Sitesi Kontrol Listesi (2026)

Aydınlatma metninden çerez banner'ına, veri saklamadan güvenlik tedbirlerine kadar bir siteyi yayına almadan önce geçtiğim KVKK kontrol listesi.

Web Geliştirme
7 dk okuma

TypeScript ile Uçtan Uca Tip Güvenliği

Form, doğrulama, API ve veritabanı zincirini tek şemadan besleyerek tip hatalarını canlıdan derleme zamanına çeken pratik bir kurulum.

Sırada ne var?

Bir fikriniz mi var? Hayata geçirelim.

merhaba@selupucin.com

Genelde 24 saat içinde dönüş yapıyorum.