Next.js 16 Metadata API: Dinamik SEO, Open Graph Görseli ve Sitemap Rehberi

Next.js 16 Metadata API'sini App Router'da nasıl kullanacağınızı, dinamik Open Graph görsellerini, sitemap üretimini ve JSON-LD yapılandırılmış veriyi gerçek üretim koduyla anlatan adım adım rehber.

Next.js 16 Metadata API: SEO (2026)

Güncellendi: 11 Haziran 2026

Next.js 16 Metadata API, App Router'da her sayfanın <head> bölümünü kod ile yöneten ve generateMetadata, opengraph-image, sitemap.ts ve robots.ts gibi dosya kuralları üzerinden dinamik SEO sinyalleri üreten birinci sınıf bir API'dir. Bu rehberde sıfırdan üretime kadar Metadata API'yi, dinamik Open Graph görsellerini, otomatik sitemap.xml üretimini ve JSON-LD yapılandırılmış veriyi gerçek kodla anlatıyorum. Pages Router'dan App Router'a geçişte en çok soru aldığım konu bu olduğu için, alışkanlıkların nerede tökezlediğini de işaretledim. Açıkçası bu yazıyı yazmamın asıl sebebi, son üç projemde Search Console'da aynı sorunları üst üste görmem oldu.

  • generateMetadata asenkron olarak Sunucu Bileşeni'nde çalışır ve params, searchParams ile dinamik title, description ve OG alanları döndürür.
  • metadataBase URL'sini tanımlamadan opengraph-image ve alternates.canonical mutlak URL üretemez; bu, üretimde gözden kaçırılan 1 numaralı hatadır.
  • opengraph-image.tsx dosyası, ImageResponse ile Edge Runtime'da 1200×630 PNG'leri istek başına üretir; statik OG için .png dosyasını koymak yeterlidir.
  • sitemap.ts ve robots.ts, ISR ile uyumlu dinamik kaynaklar olarak çalışır; CMS'ten gelen yüzlerce URL için generateSitemaps bölümlemesi şarttır.
  • JSON-LD yapılandırılmış veri, <script type="application/ld+json"> olarak Sunucu Bileşeni içinde render edilir; React 19 artık head'a script enjekte etmeyi destekler.
  • Çok dilli sitelerde alternates.languages hreflang etiketlerini otomatik üretir ve next-intl ile sorunsuz çalışır.

Next.js Metadata API nedir ve nasıl çalışır?

Metadata API, App Router'da her layout.tsx veya page.tsx dosyasından dışa aktarılan metadata nesnesi ya da generateMetadata fonksiyonu aracılığıyla sayfanın <head> içeriğini bildirimsel olarak tanımlar. Pages Router'da next/head bileşenini yerelden çağırmaya alışkın olanlar için bu, başta huzursuz edici bir değişiklik: artık <head>'e elle bir şey enjekte etmiyorsunuz; framework size bir sözleşme veriyor ve siz bir nesne döndürüyorsunuz.

Ayrıca metadata iç içe geçer (cascading): bir kök layout.tsx'te tanımladığınız title.template alt sayfalarda otomatik uygulanır, segmentteki en yakın değer kazanır. Bu, klasik <Helmet> kütüphanelerindeki yarış koşullarını ortadan kaldırır çünkü tüm metadata sunucu tarafında, render edilen ağaca göre çözülür. Üretimde gözlemlediğim faydalar şu üçlüdür: tarayıcıya gönderilen JS yükü azalır, sosyal botlar (Twitterbot, Facebook crawler) her zaman doğru meta etiketleri görür ve önbellek anahtarlarına metadata da dahil edilir. Resmi davranışı Next.js generateMetadata referansında okuyabilirsiniz.

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

export const metadata: Metadata = {
  metadataBase: new URL('https://ornek-site.com'),
  title: {
    default: 'Örnek Site (Next.js 16 ile üretildi)',
    template: '%s | Örnek Site',
  },
  description: 'Next.js 16 üzerine inşa edilmiş örnek site.',
  openGraph: {
    siteName: 'Örnek Site',
    locale: 'tr_TR',
    type: 'website',
  },
};

Statik metadata ile generateMetadata arasındaki fark

İki tür metadata vardır: derleme zamanında bilinen statik metadata nesnesi ve istek/segment bazında çözülen dinamik metadata fonksiyonu. Statik nesneyi page.tsx'ten export const metadata olarak dışa aktarırsınız ve Next.js, segment force-static ise bunu prerender sırasında sayfanın HTML'ine gömer. Bu yol bloglar, kurumsal sayfalar ve sabit pazarlama sayfaları için yeterli.

Ancak içerik bir veritabanından, CMS'ten ya da kullanıcı oturumundan geliyorsa generateMetadata kullanırsınız. Fonksiyon, sayfanın kendisiyle aynı params ve searchParams'ı alır ve async olabilir. Sayfa bileşeni ve generateMetadata aynı veriyi çekiyorsa, fetch() çağrılarınız Next 16'nın use cache yönlendirmesi sayesinde otomatik tekilleştirilir; aynı endpoint'i iki kez çağırmazsınız. Bu davranışı kendi sitemde test ettim ve P95 TTFB üzerinde ölçülebilir bir etki görmediğim için tereddütsüz öneriyorum. Detaylar için use cache ve ISR önbellekleme stratejileri yazımı okumanızı tavsiye ederim.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts';

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata(
  { params }: Props,
): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) return { title: 'Bulunamadı' };

  return {
    title: post.title,
    description: post.excerpt,
    alternates: { canonical: `/blog/${slug}` },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author],
      images: [{ url: `/blog/${slug}/opengraph-image`, width: 1200, height: 630 }],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
    },
  };
}

export default async function Page({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return <article>{post.body}</article>;
}

metadataBase, canonical ve alternates ayarı

metadataBase, Open Graph görselleri, canonical URL ve alternate dil etiketleri için göreli yolları mutlak URL'ye çeviren temel adrestir. Tanımlamazsanız Next.js VERCEL_URL'i veya http://localhost:3000'i tahmin eder. İkinci durum üretimde paylaşıldığında sosyal medya botları "Open Graph görseli bulunamadı" hatası verir. Bunu kök layout.tsx'te bir kez tanımlayın ve unutun. Bu hatayı son projemde tam canlıya alma sabahı yakaladım; o yüzden artık şablonuma ilk yazdığım satır oluyor.

Canonical URL, içerik kopyalama problemini çözer. Aynı içerik ?utm_source=newsletter ile birden fazla URL'de görünüyorsa, alternates.canonical Google'a hangisinin "asıl" sürüm olduğunu söyler. Çok dilli sitelerde alternates.languages ile hreflang etiketleri otomatik üretilir, bu özellikle next-intl ile çoklu dil yapılandırması sırasında elinizi rahatlatır. Search Console'da "Duplicate, Google chose different canonical" uyarılarını sıfırlayan en hızlı yol budur.

// app/[locale]/blog/[slug]/page.tsx
export async function generateMetadata({ params }): Promise<Metadata> {
  const { locale, slug } = await params;
  return {
    alternates: {
      canonical: `/${locale}/blog/${slug}`,
      languages: {
        'tr-TR': `/tr/blog/${slug}`,
        'en-US': `/en/blog/${slug}`,
        'de-DE': `/de/blog/${slug}`,
        'x-default': `/en/blog/${slug}`,
      },
    },
  };
}

Dinamik Open Graph görseli nasıl üretilir?

Dinamik Open Graph görseli, segment klasörünüze opengraph-image.tsx dosyası eklenerek üretilir. Next.js bu rotayı otomatik olarak /blog/<slug>/opengraph-image URL'ine bağlar ve istek başına 1200×630 PNG döndürür. Görsel, ImageResponse sınıfı ile JSX'ten render edilir, yani bir <canvas> ile uğraşmadan, normal React mark-up ile PNG çıkarırsınız. Bu sınıf Edge Runtime'da çalıştığı için soğuk başlangıç gecikmesi neredeyse yoktur; üretim sitelerimde 80–150 ms aralığında ölçüyorum.

Statik bir OG görseli koyacaksanız opengraph-image.png dosyasını segment klasörüne bırakmanız yeterli. Dinamik versiyonda dikkat edilecek tek konu: ImageResponse içinde yalnızca satır içi stiller ve sınırlı CSS desteği vardır (Flexbox evet, Grid hayır); Tailwind kullanmak istiyorsanız tw öneki ile gelen Vercel OG dokümantasyonundaki yardımcıyı kullanın. Özel font yüklemeyi de unutmayın. Varsayılan font yalnızca temel Latin karakterleri kapsar ve Türkçe karakterler "ğ ş ı" eksik render olur, ki bu hatayı bir keresinde demo sunum başlamadan beş dakika önce yakaladım. Hoş bir his değil.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
import { getPost } from '@/lib/posts';

export const runtime = 'edge';
export const alt = 'Blog yazısı kapak görseli';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);

  const fontData = await fetch(
    new URL('./Inter-Bold.ttf', import.meta.url),
  ).then((res) => res.arrayBuffer());

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          padding: 80,
          background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
          color: 'white',
          fontFamily: 'Inter',
        }}
      >
        <div style={{ fontSize: 32, opacity: 0.7 }}>ornek-site.com</div>
        <div style={{ fontSize: 64, fontWeight: 700, lineHeight: 1.1 }}>
          {post?.title ?? 'Yazı bulunamadı'}
        </div>
        <div style={{ fontSize: 28, opacity: 0.9 }}>
          {post?.author ?? 'Yazar'} · {post?.readMinutes ?? 0} dk okuma
        </div>
      </div>
    ),
    {
      ...size,
      fonts: [{ name: 'Inter', data: fontData, style: 'normal', weight: 700 }],
    },
  );
}

sitemap.xml ve robots.txt otomatik üretimi

app/sitemap.ts dosyası, MetadataRoute.Sitemap dizisi döndüren tek bir fonksiyon yazmanızı bekler; Next.js bu dosyayı otomatik olarak /sitemap.xml rotasına bağlar ve XML üretir. Pages Router'da çoğu kişinin next-sitemap paketine ihtiyaç duyduğunu hatırlayanlar için bu, ekstradan bağımlılık ortadan kalktığı için kuvvetli bir basitleştirme. Aynı şey robots.ts için de geçerli; MetadataRoute.Robots nesnesi döndürürsünüz, /robots.txt otomatik servis edilir.

CMS'ten 50.000 URL çekecekseniz tek bir sitemap dosyası yetmez. Google başına 50.000 URL ve 50 MB sınırı uygular. generateSitemaps ile sitemap'i bölümlere ayırın ve Next.js otomatik olarak bir sitemap-index.xml üretir. ISR'i de unutmayın: dosyaya export const revalidate = 3600 ekleyerek sitemap'i her saat yeniler, içerik ekiplerinin yeni yazıları manuel deploy beklemeden indekslenmesini sağlarsınız. Sitemap stratejisi konusunda Google'ın sitemap yapım kılavuzu hâlâ en otoriter kaynak.

// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';

export const revalidate = 3600;

export async function generateSitemaps() {
  return [{ id: 0 }, { id: 1 }, { id: 2 }];
}

export default async function sitemap({
  id,
}: {
  id: number;
}): Promise<MetadataRoute.Sitemap> {
  const PAGE_SIZE = 50_000;
  const posts = await getAllPosts({ skip: id * PAGE_SIZE, take: PAGE_SIZE });

  return posts.map((post) => ({
    url: `https://ornek-site.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: 'weekly',
    priority: 0.8,
    alternates: {
      languages: {
        tr: `https://ornek-site.com/tr/blog/${post.slug}`,
        en: `https://ornek-site.com/en/blog/${post.slug}`,
      },
    },
  }));
}
// app/robots.ts
import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      { userAgent: '*', allow: '/', disallow: ['/admin/', '/api/'] },
      { userAgent: 'GPTBot', disallow: '/' },
    ],
    sitemap: 'https://ornek-site.com/sitemap.xml',
    host: 'https://ornek-site.com',
  };
}

JSON-LD yapılandırılmış veri ekleme

JSON-LD yapılandırılmış veri, sayfanıza zengin sonuç (rich result; yıldız değerlendirmesi, FAQ kartı, makale yazar bilgisi gibi) kazandıran schema.org Article tipini izleyen JSON bloklarıdır. App Router'da bunu eklemenin en temiz yolu, <script type="application/ld+json"> etiketini doğrudan Sunucu Bileşeni'nin JSX'i içinde render etmektir. React 19 bu etiketi kaldırıp <head>'e taşır, manuel olarak next/head aramanıza gerek yok.

FAQ ve "How-To" yapılandırılmış verilerin Google sonuçlarındaki tıklama oranını yükselttiğini kendi proje sitelerimde gözlemledim, özellikle "nasıl yapılır" türü uzun kuyruk sorgularda. Yalnız dikkat: schema.org'da olmayan alanları yazıp şişirmenin getirisi yok; Article için headline, author, datePublished, image yeterli. Üretimde JSON.stringify'ı doğrudan dangerouslySetInnerHTML içine yazıyorum çünkü React, JSON.stringify çıktısını XSS'e karşı zaten güvenli serileştirir; ancak HTML'i kontrol altında değil de kullanıcıdan geliyorsa, açıkça temizlemeniz şarttır.

// app/blog/[slug]/page.tsx
export default async function Page({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    image: [`https://ornek-site.com/blog/${slug}/opengraph-image`],
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: [{ '@type': 'Person', name: post.author }],
    publisher: {
      '@type': 'Organization',
      name: 'Örnek Site',
      logo: { '@type': 'ImageObject', url: 'https://ornek-site.com/logo.png' },
    },
  };

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

viewport, theme-color ve Twitter Card detayları

Next.js 14.2'den itibaren viewport ve themeColor ayarları metadata nesnesinden çıkarıldı; ayrı bir viewport dışa aktarımı ya da generateViewport fonksiyonu kullanmanız gerekiyor. App Router'a sonradan geçen birçok projede bu deprecation uyarısının pratikte gözden kaçtığını gördüm. Derleme uyarı verir ama hata değildir. Üretimde bunu temizleyince Lighthouse'taki "Has a <meta name=viewport> tag" denetimi yeniden yeşile döner.

Twitter (X) Card için twitter nesnesi, Open Graph ile aynı yapıya sahiptir ancak Twitter'ın kendi alanlarını destekler: card, site, creator. Çoğu durumda summary_large_image doğru seçimdir; summary küçük kare görsel üretir, blog yazılarınız için yetersizdir. Theme color'u açık/koyu mod için ayrı tanımlamak da artık mümkün, Safari'nin mobil tarayıcı çubuğunu doğru renge boyar. Yapılandırılmış veri için ekstra üretim öncesi i18n SEO rehberindeki hreflang kontrol bölümünü tekrar gözden geçirmenizi öneririm.

// app/layout.tsx
import type { Viewport } from 'next';

export const viewport: Viewport = {
  width: 'device-width',
  initialScale: 1,
  maximumScale: 5,
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#0f172a' },
  ],
  colorScheme: 'light dark',
};

export const metadata: Metadata = {
  twitter: {
    card: 'summary_large_image',
    site: '@ornek_site',
    creator: '@ben',
  },
};
AlanPages Router (next/head)App Router (Metadata API)
Tanım yeriBileşen içinde <Head>export const metadata veya generateMetadata
Asenkron verigetServerSideProps ile dolaylıDoğrudan async fonksiyon
İç içe mirasYok, her sayfa kendi başınaLayout cascade ile otomatik
OG görseliÜçüncü taraf paketYerleşik opengraph-image.tsx
Sitemapnext-sitemap paketiYerleşik sitemap.ts
HreflangManuel etiket eklemealternates.languages otomatik

Yaygın hatalar ve üretim kontrol listesi

En sık karşılaştığım hatalar şunlar. metadataBase yok: sosyal botlar göreli görsel URL'ini çözemez ve OG kartı boş görünür. params await edilmedi: Next 15+ derleme uyarısı verir, üretimde sessiz hatalı render üretebilir. JSON-LD'de yanlış @type: Search Console "Geçerli olmayan yapılandırılmış veri" uyarısı düşer; Google'ın Rich Results test aracıyla sürüm öncesi doğrulayın. Sitemap'te localhost URL'leri: derleme sırasında NEXT_PUBLIC_SITE_URL tanımlı değilse http://localhost:3000'a düşer ve sitemap işe yaramaz hale gelir.

Üretim öncesi kontrol listem şöyle: (1) kök layout'ta metadataBase ayarlı mı, (2) her sayfa için canonical mevcut mu, (3) OG görseli sosyal medya debug araçlarında (Facebook Sharing Debugger, Twitter Card Validator) doğru görünüyor mu, (4) sitemap.xml ve robots.txt üretimde 200 dönüyor mu, (5) JSON-LD Rich Results testinden geçiyor mu, (6) hreflang etiketleri her dil için karşılıklı mı (tr→en var ama en→tr yoksa Google görmezden gelir). Bu altı maddenin altını çizdiğiniz an SEO temeli sağlam demektir.

Sıkça Sorulan Sorular

Next.js'te dinamik meta etiketleri nasıl eklenir?

page.tsx veya layout.tsx dosyasından generateMetadata adında bir async fonksiyon dışa aktarın; fonksiyon, sayfanın aldığı params ve searchParams'ı alır ve Metadata nesnesi döndürür. Next.js bu nesneyi sunucuda render eder ve istemciye gönderilen HTML'de <head>'e yazar.

Open Graph görseli neden boş ya da varsayılan görünüyor?

En yaygın sebep: metadataBase tanımlı değil ve görsel URL'i göreli (örn. /og.png). Sosyal botlar mutlak URL bekler. Kök layout.tsx'te metadataBase: new URL('https://siteniz.com') tanımlayın ve Facebook Sharing Debugger ile yeniden kazıyın.

sitemap.xml otomatik olarak nasıl üretilir?

app/sitemap.ts dosyası oluşturun ve MetadataRoute.Sitemap dizisi döndüren varsayılan bir fonksiyon dışa aktarın. Next.js bunu /sitemap.xml'e bağlar. 50.000'den fazla URL için generateSitemaps ile bölümleyin ve export const revalidate ile ISR ekleyin.

App Router'da JSON-LD yapılandırılmış veriyi nereye eklemeliyim?

Doğrudan Sunucu Bileşeni'nin JSX'i içinde, <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} /> olarak yazın. React 19 bu etiketi otomatik olarak <head>'e taşır. next/head kullanmayın; o paket App Router'da işe yaramaz.

Canonical URL'i Next.js'te nasıl ayarlarım?

metadata.alternates.canonical alanına göreli yolu yazın (örn. /blog/yazi-slug). Next.js, metadataBase ile birleştirerek tam URL üretir. Çok dilli sitelerde aynı bloğa alternates.languages ekleyerek hreflang etiketlerini otomatik bastırırsınız.

Ben Howard
Yazar Hakkında Ben Howard

Full-stack Next.js developer who's been with the framework since pages-only days. Slowly warming up to App Router.