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:
// 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:
// 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,
},
};
}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:
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.
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.
// 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];
}// app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: "https://czay.dev/sitemap.xml",
};
}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ı:
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.
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:
{
"@context": "https://schema.org",
"@graph": [
{ "@type": "Service", "@id": `${url}#service`, name: "Mobil uygulama", /* … */ },
{ "@type": "BreadcrumbList", "@id": `${url}#breadcrumb`, /* … */ },
{ "@type": "FAQPage", "@id": `${url}#faq`, /* … */ },
],
}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:
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:
app/blog/[slug]/opengraph-image.tsxBu 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
- Kök
layout.tsx'temetadataBaseve başlık şablonu var mı? - Her sayfanın
alternates.canonicalalanı dolu mu? generateMetadatadinamik sayfalardapublishedTimevemodifiedTimeveriyor mu?sitemap.tsiçindelastModifiedgerçekten son güncelleme tarihi mi?- Aramada ayrı bulunmasını istediğin sayfalar site haritasında mı?
- Dinamik rotalarda
generateStaticParamsyazıldı mı? - JSON-LD
JSON.stringifyile mi basılıyor? - Şemada yazan her şey sayfada gerçekten var mı?
robots.tssite 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
