Next.js App Router ile teknik SEO: meta, sitemap, kanonik ve JSON-LD
Next.js App Router, teknik SEO'nun büyük kısmı için hazır dosya kuralları sunuyor: metadata nesnesi, sitemap.ts, robots.ts, opengraph-image. Ama hazır olması doğru kullanıldığı anlamına gelmiyor. Sitelerin kaynak kodunda en sık görülen sorunlar, her sayfada aynı başlık, ana sayfayı gösteren kanonik adres ya da hiç güncellenmeyen bir sitemap oluyor.
Bu yazıda bir Next.js sitesinde teknik SEO'yu nasıl kurduğumu adım adım anlatıyorum. Kod örnekleri Next.js 16 belgeleriyle uyumlu. Yer yer portföyümdeki hukuk bürosu sitesi çalışmasından örnek veriyorum: iki dilli (TR/EN), 53 sayfası statik üretilen bir site.
Kök yerleşimde temel ayarlar
Her şey kök layout.tsx içindeki metadata nesnesiyle başlıyor. Burada iki ayar işinizi çok kolaylaştırıyor: metadataBase ve başlık şablonu.
// app/layout.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
metadataBase: new URL('https://ornekfirma.com'),
title: {
default: 'Örnek Firma',
template: '%s | Örnek Firma',
},
description: 'Adana merkezli nakliye ve depolama hizmetleri.',
openGraph: {
siteName: 'Örnek Firma',
locale: 'tr_TR',
type: 'website',
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="tr">
<body>{children}</body>
</html>
);
}
metadataBase tanımlandığında kanonik adres, Open Graph görseli gibi tam adres isteyen alanlara göreli yol yazabiliyorsunuz; Next.js bunları tam adrese çeviriyor. Tanımlamadan göreli yol kullanırsanız derleme hata veriyor. title.template ise alt sayfalardaki başlıkların sonuna marka adını ekliyor. Bir incelik var: şablon yalnızca alt segmentlere uygulanıyor, tanımlandığı segmentin kendi sayfasına uygulanmıyor ve şablon kullanıyorsanız default değeri zorunlu.
<html lang="tr"> de SEO'dan çok erişilebilirlikle ilgili gibi görünse de arama motoruna ve ekran okuyuculara sayfanın dilini söyleyen ilk yer.
Sayfa başına meta ve kanonik adres
Dinamik sayfalarda generateMetadata kullanılıyor. Next.js 15'ten beri params bir Promise, await etmeniz gerekiyor.
// app/hizmetler/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getHizmet } from '@/lib/hizmetler';
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const hizmet = await getHizmet(slug);
if (!hizmet) return {};
return {
title: hizmet.baslik,
description: hizmet.ozet,
alternates: { canonical: `/hizmetler/${slug}` },
openGraph: {
title: hizmet.baslik,
description: hizmet.ozet,
url: `/hizmetler/${slug}`,
},
};
}
export default async function Page({ params }: Props) {
const { slug } = await params;
const hizmet = await getHizmet(slug);
if (!hizmet) notFound();
// ...
}
Burada en çok karşılaşılan hata şu: kanonik adresi kök yerleşimde alternates: { canonical: '/' } olarak tanımlamak. Metadata üst segmentten miras kaldığı için, kendi kanonik adresini tanımlamayan her alt sayfa ana sayfayı kanonik olarak göstermeye başlıyor. Bu, Google'a "bu sayfa ana sayfanın kopyası" demek. Kanonik adresi her sayfada kendisi tanımlasın; kök yerleşime koymayın.
Kanonik adreste tutarlılık da önemli: sonda eğik çizgi olup olmayacağına, www kullanılıp kullanılmayacağına bir kez karar verin, yönlendirmeler, sitemap ve kanonik etiketler aynı biçimi kullansın.
Çok dilli sitelerde alternates.languages ile hreflang bağlantılarını da buraya ekliyorsunuz. Hukuk bürosu sitesinde her sayfa kendi kanonik adresini, diğer dildeki karşılığını ve bir x-default adresini tanımlıyor; /tr/uzmanlik-alanlari/is-hukuku ile /en/practice-areas/employment-law birbirini gösteriyor.
sitemap.ts ve robots.ts
App Router'da app/sitemap.ts dosyası /sitemap.xml adresinde sunuluyor. İçeriği veritabanından ya da içerik dosyalarından üretebildiğiniz için yeni sayfa eklendiğinde elle güncellemeniz gerekmiyor.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { tumHizmetler } from '@/lib/hizmetler';
const SITE = 'https://ornekfirma.com';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const hizmetler = await tumHizmetler();
return [
{ url: `${SITE}/`, lastModified: new Date('2026-09-01') },
{ url: `${SITE}/iletisim`, lastModified: new Date('2026-06-15') },
...hizmetler.map((h) => ({
url: `${SITE}/hizmetler/${h.slug}`,
lastModified: h.guncellenme,
})),
];
}
Sitemap'te mutlak adres gerekiyor; metadataBase burada devreye girmiyor. Google'ın belgelerine göre priority ve changefreq alanları dikkate alınmıyor, lastmod ise ancak tutarlı ve doğru olduğunda kullanılıyor. Bu yüzden lastModified'ı her derlemede new Date() yapmak yerine içeriğin gerçek güncellenme tarihine bağlamak daha iyi. Hukuk bürosu sitesinde makalelerin tarihi içerik dosyasından geliyor; sabit sayfalar ise derleme tarihini kullanıyor, ki bu da iyileştirmeye açık bir nokta.
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/yonetim'] }],
sitemap: 'https://ornekfirma.com/sitemap.xml',
};
}
Unutulmaması gereken nokta: robots.txt taramayı engeller, dizine eklenmeyi değil. Bir sayfanın arama sonuçlarında görünmesini istemiyorsanız o sayfaya noindex verin:
export const metadata: Metadata = {
robots: { index: false, follow: false },
};
noindex verdiğiniz bir sayfayı aynı zamanda robots.txt ile engellerseniz Google sayfayı taramadığı için noindex etiketini hiç göremez. İkisinden birini seçin.
Test ya da önizleme ortamı için ayrı bir önlem alın: ortam değişkenine göre robots.ts içinde her şeyi engelleyin ve sayfalara noindex ekleyin. Önizleme adresinin Google'a düşmesi, sonradan temizlemesi zahmetli bir sorun.
JSON-LD ile yapılandırılmış veri
Yapılandırılmış veri, sayfadaki bilginin ne olduğunu (bir işletme, bir makale, bir ürün) makinenin okuyabileceği biçimde anlatır. Next.js belgelerinin önerisi, JSON-LD'yi sayfa ya da yerleşim bileşeninde düz bir <script> etiketiyle basmak:
// app/page.tsx
export default function Page() {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'LocalBusiness',
name: 'Örnek Firma',
url: 'https://ornekfirma.com',
telephone: '+90-322-000-0000',
address: {
'@type': 'PostalAddress',
addressLocality: 'Seyhan',
addressRegion: 'Adana',
addressCountry: 'TR',
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>
{/* sayfa içeriği */}
</>
);
}
replace(/</g, '\\u003c') kısmını atlamayın. İçerik bir veritabanından ya da kullanıcı girdisinden geliyorsa, JSON.stringify tek başına </script> gibi bir diziyi etkisiz hale getirmez. Bu bir <script> olsa da çalıştırılabilir kod değil, veri; bu yüzden next/script yerine düz etiket kullanılıyor.
Hangi türü kullanacağınız işinize bağlı. Hukuk bürosu sitesinde LegalService, Person, Article, FAQPage ve BreadcrumbList var; portföyümdeki klinik sitesinde MedicalClinic ve Physician, outdoor e-ticaret mağazasının ürün sayfalarında Product. Bir uyarı: Google, SSS zengin sonuçlarını Mayıs 2026 itibarıyla hiçbir sitede göstermiyor. FAQPage işaretlemesini kaldırmak zorunda değilsiniz, sayfayı anlamak için hâlâ okunuyor, ama arama sonucunda açılır soru-cevap görmeyi beklemeyin. Yapılandırılmış veri bir sıralama garantisi değil; sayfanın ne olduğunu netleştiren bir araç.
Doğrulama için Google'ın Rich Results Test aracını ve schema.org'un Schema Markup Validator'ını kullanıyorum. Bir kural da şu: JSON-LD'deki bilgi sayfada görünen bilgiyle aynı olmalı. Sayfada olmayan bir puanı ya da fiyatı yapılandırılmış veriye yazmak, Google'ın yönergelerine aykırı.
Yayından önce kontrol listesi
- Her sayfanın kendine ait
titlevedescriptiondeğeri var; kopya başlık yok. - Kanonik adres her sayfada o sayfanın kendisini gösteriyor, kök yerleşimde tanımlı değil.
/sitemap.xmlyalnızca dizine eklenmesini istediğiniz, 200 dönen, kanonik adresleri içeriyor./robots.txtyönetim panelini engelliyor, sitemap adresini gösteriyor; önizleme ortamı tamamen kapalı.- JSON-LD doğrulama araçlarından hatasız geçiyor ve sayfadaki bilgiyle örtüşüyor.
- Yayından sonra Search Console'a sitemap'i gönderin ve Sayfa dizine ekleme raporunu birkaç hafta takip edin.
Hukuk bürosu çalışmasında SEO uç noktalarını Playwright ile yazdığım uçtan uca testlerin içine koydum: sayfalarda beklenen JSON-LD türleri var mı, sitemap'te hreflang ve bütün rotalar yer alıyor mu, robots.txt yönetim panelini kapatıyor mu. Bir değişiklik bunlardan birini bozarsa test kırılıyor. Küçük bir sitede bile bu kontrolleri otomatiğe bağlamak, yayından sonra fark edilen sorunları ciddi şekilde azaltıyor.
Next.js sitenizde bu kurulumu yaptırmak ya da mevcut durumu kontrol ettirmek isterseniz bana yazabilirsiniz.