Next.js'te SEO: metadata, sitemap, robots ve yapısal veri

Next.js'te SEO: metadata, sitemap, robots ve yapısal veri

App Router'da SEO'nun mekaniği — generateMetadata, canonical, sitemap'te lastModified'ı doğru vermek, JSON-LD'yi @graph ile kurmak ve hangi zengin sonuçların hâlâ yaşadığı.

Furkan Özay·2 Eylül 2026·5 dk okuma

Next.js'te SEO, eklenti kurma işi değil — birkaç dosya ve birkaç fonksiyon. Ama hangisinin ne işe yaradığı karışık, çoğu rehber de eskimiş bilgiyle dolaşıyor.

Bu yazı App Router'daki SEO yüzeylerini tek tek anlatıyor. Örneklerin hepsi şu an okuduğun sitede çalışan koddan.

1. Metadata: statik ve üretilmiş

İki biçim var. Sabit sayfalar için metadata nesnesi:

TypeScript
// app/studio/page.tsx
export const metadata: Metadata = {
	title: "Czay Studio",
	description: "Kurumlar ve markalar için teslim ettiğim işler…",
	alternates: { canonical: "https://czay.dev/studio" },
	openGraph: {
		title: "Czay Studio | Furkan Özay",
		description: "…",
		url: "https://czay.dev/studio",
	},
};

Dinamik sayfalar için generateMetadata:

TypeScript
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
	const { slug } = await params;
	const post = getPost(slug);
	if (!post) return {};
 
	return {
		title: post.title,
		description: post.description,
		alternates: { canonical: `https://czay.dev/blog/${slug}` },
		openGraph: {
			title: `${post.title} | Furkan Özay`,
			description: post.description,
			url: `https://czay.dev/blog/${slug}`,
			type: "article",
			publishedTime: post.date,
			modifiedTime: post.updated ?? post.date,
			authors: ["Furkan Özay"],
			tags: post.tags,
		},
	};
}
⚠️modifiedTime'ı unutma

Bu satırı bu yazıyı yazarken ekledim. Sitede publishedTime vardı ama modifiedTime yoktu: elden geçirdiğim yazılar paylaşımda ve tarayıcılarda hep ilk hâliymiş gibi görünüyordu. Yazı güncelleme alışkanlığın varsa bu alan tazeliği bildiren en ucuz sinyal.

Kök şablon

layout.tsx'te başlık şablonu tanımlarsan her sayfada marka adını tekrar yazmıyorsun:

TypeScript
export const metadata: Metadata = {
	metadataBase: new URL("https://czay.dev"),
	title: {
		default: "Furkan Özay — Fullstack Developer & Eğitmen",
		template: "%s | Furkan Özay",
	},
};

metadataBase özellikle önemli: onsuz göreli Open Graph görsel adresleri mutlak adrese çevrilmiyor ve paylaşım kartları boş çıkıyor.

2. Canonical: en çok atlanan alan

Her sayfa kendi kanonik adresini bildirmeli. Bunu yazmadığında aynı içeriğe farklı yollardan ulaşan adresler (izleme parametreleri, sondaki eğik çizgi, www varyantı) ayrı sayfa sayılıyor ve sinyal bölünüyor.

TypeScript
alternates: { canonical: "https://czay.dev/studio/mobile" }

Blogda filtre durumunu adres çubuğunda tutuyorsan bu daha da önemli: ?konu=nextjs ile açılan liste ayrı bir sayfa değil, aynı sayfanın bir görünümü.

3. sitemap.ts ve robots.ts

İkisi de birer dosya; XML yazmıyorsun.

TypeScript
// app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
	const posts = getAllPosts().map((post) => ({
		url: `https://czay.dev/blog/${post.slug}`,
		lastModified: new Date(post.updated ?? post.date),
		changeFrequency: "monthly" as const,
		priority: 0.7,
	}));
 
	return [...staticPages, ...posts];
}
TypeScript
// app/robots.ts
export default function robots(): MetadataRoute.Robots {
	return {
		rules: { userAgent: "*", allow: "/" },
		sitemap: "https://czay.dev/sitemap.xml",
	};
}
🚨lastModified'ı yayın tarihiyle doldurma

Bu da bu yazıyı yazarken düzelttiğim bir hataydı: site haritası new Date(post.date) kullanıyordu, yani updated alanını yok sayıyordu. Altı yazıyı elden geçirmiştim ve site haritası hepsini hâlâ eski tarihleriyle bildiriyordu — yeniden taranma sinyali boşa gidiyordu.

Doğrusu: new Date(post.updated ?? post.date)

Site haritasına hangi sayfaların girdiğine de dikkat et. Bu sitede hizmet detay sayfaları ayrı ayrı bulunsun diye var; site haritasında olmamaları o amacı boşa çıkarırdı:

TypeScript
const servicePages = studioServices
	.filter((service) => service.detail)
	.map((service) => ({
		url: `https://czay.dev/studio/${service.id}`,
		changeFrequency: "monthly" as const,
		priority: 0.8,
	}));

4. Yapısal veri (JSON-LD)

Next.js'in bunun için bir API'si yok; script etiketini kendin basıyorsun.

TSX
const jsonLd = {
	"@context": "https://schema.org",
	"@type": "BlogPosting",
	headline: post.title,
	datePublished: post.date,
	dateModified: post.updated ?? post.date,
	author: { "@type": "Person", name: "Furkan Özay" },
};
 
export default function Page() {
	return (
		<>
			<script
				type="application/ld+json"
				dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
			/>
			{/* … */}
		</>
	);
}

JSON.stringify kullanmak önemli: metni elle birleştirirsen içerikteki tırnak ve < karakterleri şemayı bozuyor.

Birden fazla şemayı @graph ile kur

Bir sayfada birden çok varlık tanımlaman gerekiyorsa ayrı ayrı script etiketleri yerine tek bir @graph kullan — varlıklar @id ile birbirine bağlanabiliyor:

TypeScript
{
	"@context": "https://schema.org",
	"@graph": [
		{ "@type": "Service", "@id": `${url}#service`, name: "Mobil uygulama", /* … */ },
		{ "@type": "BreadcrumbList", "@id": `${url}#breadcrumb`, /* … */ },
		{ "@type": "FAQPage", "@id": `${url}#faq`, /* … */ },
	],
}
Hangi zengin sonuç hâlâ yaşıyor?

Burada eskimiş tavsiye çok. Güncel durum:

  • BreadcrumbList — hâlâ çalışıyor; arama sonucunda "site › bölüm › sayfa" kırılımını çıkarıyor. Uygulaması kolay, getirisi net.
  • Article / BlogPosting — zengin sonuç kutusu vermiyor ama tarih, yazar ve güncellenme bilgisini makineye açıyor.
  • FAQPage — Google, SSS zengin sonuçlarını Ağustos 2023'te yalnız yetkili kurumlara kısıtladı ve 7 Mayıs 2026'da tamamen kaldırdı. İşaretleme hâlâ geçerli ve Google dışı tüketiciler okuyor, ama Google'da bir kutu beklemek artık yanlış.

Yani "SSS şeması ekleyip zengin sonuç alalım" tavsiyesi eskidi. Yapısal veriyi sayfayı tarif etmek için kullan, kutu avlamak için değil.

Şemaya ekranda olmayan şey girmez

Yapısal veri sayfadaki içeriği tarif eder. Sayfada olmayan bir SSS'yi şemaya yazmak, arama motorlarının politikasına aykırı ve yaptırımı var.

5. Statik üretim ve generateStaticParams

SEO'nun görünmeyen yarısı hız. Dinamik rotalarda bunu yazmazsan her sayfa her istekte yeniden üretiliyor:

TypeScript
export async function generateStaticParams() {
	return getAllPosts().map((post) => ({ slug: post.slug }));
}

Aynısı hizmet detay sayfaları için de geçerli — sonlu ve bilinen bir liste varsa hepsi derleme anında üretilmeli.

6. Open Graph görselleri

Next.js görselleri kodla üretebiliyor; her yazı için ayrı görsel hazırlamana gerek yok:

plaintext
app/blog/[slug]/opengraph-image.tsx

Bu dosya bir React bileşeni döndürüyor ve Next.js onu 1200×630 bir görsele çeviriyor. Başlığı, etiketleri ve okuma süresini içine basabiliyorsun.

7. RSS hâlâ değerli

app/rss.xml/route.ts ile besleme üretmek birkaç satır. Okuyucu sayısı az görünse de RSS, içeriğinin toplayıcılara ve bültenlere ulaşmasının en basit yolu — ve oradan gelen bağlantılar gerçek.

Kontrol listesi

📋Yayına almadan önce
  • Kök layout.tsx'te metadataBase ve başlık şablonu var mı?
  • Her sayfanın alternates.canonical alanı dolu mu?
  • generateMetadata dinamik sayfalarda publishedTime ve modifiedTime veriyor mu?
  • sitemap.ts içinde lastModified gerçekten son güncelleme tarihi mi?
  • Aramada ayrı bulunmasını istediğin sayfalar site haritasında mı?
  • Dinamik rotalarda generateStaticParams yazıldı mı?
  • JSON-LD JSON.stringify ile mi basılıyor?
  • Şemada yazan her şey sayfada gerçekten var mı?
  • robots.ts site haritasını bildiriyor mu?

Bu listedeki iki maddeyi bu yazıyı yazarken kendi sitemde düzelttim. SEO'nun zor tarafı bilmemek değil; kurduktan sonra bir daha bakmamak.

İlgili yazı: App Router ile MDX: blog kurmanın doğru yolu

Bu yazıyı faydalı bulduysan paylaşabilirsin.

Furkan Özay

Yazılım geliştirici ve eğitmen; Next.js, React ve altyapı üzerine yazıyor.

czay.dev

Bunu da oku

Yorumlar