Next.js Metadata API: Dynamische SEO mit generateMetadata im App Router (2026)

Praktischer Guide zur Next.js Metadata API: statisch und dynamisch mit generateMetadata, Open-Graph-Bilder via ImageResponse, JSON-LD Rich Results, sitemap.ts und robots.ts, plus konkrete Fehlerbehebung aus echten Produktions-Deploys.

Aktualisiert: 17. August 2026

Die Next.js Metadata API ist das offizielle System des App Routers, um SEO-relevante Meta-Tags, Open-Graph-Bilder, Twitter Cards, sitemap.xml, robots.txt und JSON-LD direkt aus Server Components heraus zu generieren, entweder statisch über den metadata-Export oder dynamisch über generateMetadata(). Sie ersetzt next/head vollständig, läuft ausschließlich auf dem Server und wird von Next.js zur Build- oder Request-Zeit in valides HTML gerendert.

Ehrlich gesagt: Als ich meinen ersten größeren Blog vor ein paar Monaten von next/head auf die neue API migriert habe, waren die ersten zwei Stunden pure Freude, und die nächsten vier ein Kampf gegen leere <title>-Tags im Produktions-Build. Genau diese Stolperfallen (und wie Sie sie vermeiden) nimmt dieser Guide vorweg, damit Sie 2026 mit Next.js 16 saubere, indexierbare und Rich-Result-fähige Seiten ausliefern, ohne dieselbe Runde Debugging.

  • generateMetadata() ist eine async Server-Funktion, die dieselben params/searchParams wie die Page erhält und Ihnen dynamische Titel, Descriptions und OG-Daten aus der Datenbank ermöglicht.
  • Metadata aus Layouts und Pages werden gemergt, nicht ersetzt – title.template und title.default steuern das Verhalten site-weit.
  • Open-Graph-Bilder erzeugen Sie mit opengraph-image.tsx plus ImageResponse aus next/og; sie werden pro Request auf dem Edge Runtime gerendert.
  • Dateibasierte Konventionen (sitemap.ts, robots.ts, manifest.ts, favicon.ico) ersetzen manuell gepflegte Public-Assets und werden automatisch mit korrekten Content-Types ausgeliefert.
  • JSON-LD Structured Data binden Sie über ein <script type="application/ld+json"> in der Server Component ein – Next.js belässt es unverändert im HTML.
  • Fetch-Calls in generateMetadata und dem Page-Body werden dedupliziert, sofern beide dieselbe Request-Signatur haben – ein doppelter Netzwerk-Roundtrip entsteht dadurch nicht.

Was ist die Metadata API in Next.js?

Die Metadata API ist ein deklaratives Interface im App Router, mit dem jede page.tsx oder layout.tsx zwei Server-Exports bereitstellen kann: eine statische Konstante metadata vom Typ Metadata oder eine asynchrone Funktion generateMetadata(). Next.js wertet beide zur Rendering-Zeit aus, kombiniert sie mit den Metadata-Objekten aller übergeordneten Layouts entlang des Route-Baums und rendert das Ergebnis in den <head> des HTML-Dokuments. Weil die Ausführung serverseitig erfolgt, haben Sie Zugriff auf Datenbanken, Environment-Variablen und geschützte APIs – anders als beim alten next/head-Ansatz im Pages Router.

Der zentrale Vorteil gegenüber manuellem Head-Management: Metadata ist an die Route gekoppelt, nicht an ein React-Rendering-Detail. Suchmaschinen-Crawler und Social-Media-Scraper sehen bereits im initial ausgelieferten HTML einen vollständigen Kopfbereich, ohne dass JavaScript ausgeführt werden muss. Das ist ein direkter Ranking-Faktor, weil sowohl Googles JavaScript-SEO-Guide als auch OpenGraph-Konsumenten wie Slack, LinkedIn oder Discord kein clientseitiges Rendering ausführen.

Ein Detail, das ich beim Umstieg unterschätzt hatte: Sobald Sie Auth einbauen und Session-basierte Titel setzen wollen (etwa „Hallo, Max" auf einer Kontoseite), passt das nicht in die Metadata-Welt, weil generateMetadata vor dem Streaming läuft. Wer sowieso mit Auth.js v5 im App Router arbeitet, sollte solche personalisierten Titel in Client-Segmenten setzen und die SEO-Metadata generisch halten.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Mein Blogartikel',
  description: 'Kurze Zusammenfassung für Suchmaschinen.',
}

export default function Page() {
  return <article>…</article>
}

Diese Datei erzeugt sowohl <title> als auch <meta name="description"> im finalen HTML. Sobald Sie datengetriebene Inhalte brauchen, ersetzen Sie den Export durch die dynamische Variante, die im nächsten Abschnitt gezeigt wird.

Statische vs. dynamische Metadata mit generateMetadata

Kurz gesagt, der Unterschied ist rein deklarativ: Die statische Variante ist ein Objektexport ohne asynchrone Auflösung und wird zur Build-Zeit einmal serialisiert. generateMetadata hingegen ist eine async-Funktion, erhält als erstes Argument dieselben params- und searchParams-Promises wie die Page-Komponente und darf beliebig lange Server-Arbeit verrichten (Fetches, Datenbank-Queries, Header-Zugriffe). Verwenden Sie generateMetadata, sobald Titel oder Beschreibung von einem dynamischen Segment abhängen, also Blogpost, Produkt, Nutzerprofil.

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

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

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata,
): Promise<Metadata> {
  const { slug } = await params
  const post = await getPostBySlug(slug)
  if (!post) return { title: 'Nicht gefunden' }

  // Bilder des Parent-Layouts erhalten, um sie zu erweitern
  const previousImages = (await parent).openGraph?.images || []

  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.name],
      images: [
        {
          url: `/blog/${slug}/opengraph-image`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
        ...previousImages,
      ],
    },
    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 getPostBySlug(slug)
  if (!post) notFound()
  return <article>{post.body}</article>
}

Wichtiges Detail: Der Aufruf getPostBySlug(slug) geschieht sowohl in generateMetadata als auch in der Page. Next.js dedupliziert diese Fetches automatisch innerhalb eines Requests, sofern sie über fetch() mit identischer URL/Optionen laufen oder in eine cache()-Funktion aus react gewrapt sind. Ohne diese Deduplication würde jede Detailseite zwei Datenbank-Roundtrips machen. Ich habe genau das mal in einer Vercel-Rechnung gesehen (Postgres-Calls verdoppelt, weil db.post.findUnique nackt aufgerufen wurde), also lieber einmal richtig aufsetzen.

Wie generiere ich Open-Graph-Bilder in Next.js 16?

Open-Graph-Bilder in Next.js 16 erzeugen Sie mit der Dateikonvention opengraph-image.tsx (oder .png, .jpg) direkt in einem Route-Segment. Wenn Sie eine .tsx-Variante verwenden, exportieren Sie eine Funktion, die ein ImageResponse aus next/og zurückgibt – ein serverseitiger Renderer, der JSX in ein 1200×630 PNG umwandelt. Das Ergebnis wird pro Request auf dem Edge Runtime generiert und ist statisch cachebar. Für statische Routes läuft der Aufruf zur Build-Zeit; für dynamische Routes zur Request-Zeit oder – bei ISR – bis zum nächsten Revalidate.

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

export const alt = 'Blogartikel'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

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

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          width: '100%',
          height: '100%',
          padding: '80px',
          background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 100%)',
          color: 'white',
          fontFamily: 'Inter',
        }}
      >
        <div style={{ fontSize: 32, opacity: 0.7 }}>Next.js Launchpad</div>
        <div style={{ fontSize: 72, fontWeight: 700, lineHeight: 1.1 }}>
          {post?.title ?? 'Artikel nicht gefunden'}
        </div>
        <div style={{ fontSize: 28, opacity: 0.8 }}>
          {post?.author.name} · {new Date(post?.publishedAt ?? Date.now()).toLocaleDateString('de-DE')}
        </div>
      </div>
    ),
    { ...size },
  )
}

Die URL dieses Bildes wird automatisch von Next.js in die Metadata eingefügt. Sie müssen sie also nicht manuell in openGraph.images referenzieren, wenn Sie sich auf die File-Convention verlassen. Nur wenn Sie zusätzliche Varianten (z. B. quadratisch für WhatsApp) brauchen, erweitern Sie das Array explizit. Für vollständige Kontrolle über das URL-Schema empfiehlt sich, die File-Convention wegzulassen und einen dedizierten Route Handler für REST-Endpoints zu bauen, der ImageResponse zurückgibt.

Metadata-Vererbung und -Zusammenführung in Layouts

Metadata verhält sich im App Router ein bisschen wie CSS-Vererbung mit Overrides: Jedes Layout entlang des Route-Baums darf sein eigenes metadata-Objekt exportieren, und die finale Metadata für eine Seite entsteht durch Merging vom Root nach unten. Skalare Felder (title, description) werden vom tiefsten Segment überschrieben. Objekte (openGraph, twitter, alternates) werden feldweise gemergt. Arrays wie keywords oder openGraph.images werden aber nicht automatisch zusammengeführt, hier müssen Sie den Parent-Wert im Kind explizit reinreichen.

Für title gibt es eine spezielle Struktur mit drei Feldern: default, template und absolute. Ein Template im Root-Layout ist der Standard-Weg, um site-weites Branding zu erzwingen, ohne jede Seite manuell zu suffigieren:

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

export const metadata: Metadata = {
  metadataBase: new URL('https://nextjslaunchpad.com'),
  title: {
    default: 'Next.js Launchpad',
    template: '%s | Next.js Launchpad',
  },
  description: 'Anleitungen für den Next.js App Router.',
  openGraph: {
    siteName: 'Next.js Launchpad',
    locale: 'de_DE',
    type: 'website',
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
}

Eine Kindseite, die nun title: 'Streaming SSR erklärt' exportiert, produziert das finale <title>Streaming SSR erklärt | Next.js Launchpad</title>. Wenn Sie das Template für eine einzelne Seite umgehen wollen (etwa für die Startseite), verwenden Sie title: { absolute: 'Next.js Launchpad, App Router Guides' }. Der metadataBase-Eintrag im Root ist zwingend erforderlich, damit relative URLs in openGraph.images und alternates.canonical zu absoluten URLs aufgelöst werden. Ohne ihn logged Next.js eine Warnung und verwirft die Werte. Passenderweise ist das genau der Punkt, den Sie in Kombination mit Streaming SSR und Suspense beachten müssen, sonst tauchen die Preview-Bilder erst nach dem hydrieren auf.

Dateibasierte Metadata: sitemap.ts, robots.ts, manifest.ts

Neben der objektbasierten Metadata unterstützt Next.js eine Reihe dateibasierter Konventionen, bei denen ein Dateiname in app/ automatisch eine Route mit korrektem Content-Type erzeugt. Die wichtigsten sind sitemap.ts, robots.ts, manifest.ts, favicon.ico, icon.tsx und apple-icon.tsx. Alle liegen im Root von app/, außer den Icons, die auch pro Segment definiert werden können.

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://nextjslaunchpad.com'
  const slugs = await getAllPostSlugs()

  const posts = slugs.map((slug) => ({
    url: `${baseUrl}/blog/${slug}`,
    lastModified: new Date(),
    changeFrequency: 'weekly' as const,
    priority: 0.7,
  }))

  return [
    { url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
    { url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    ...posts,
  ]
}
// 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://nextjslaunchpad.com/sitemap.xml',
    host: 'https://nextjslaunchpad.com',
  }
}

Für Sites mit mehr als 50.000 URLs (Googles Sitemap-Limit) exportieren Sie zusätzlich eine generateSitemaps()-Funktion, die ein Array von IDs zurückgibt. Next.js ruft Ihre Sitemap-Funktion dann pro ID auf und erzeugt einen Sitemap-Index unter /sitemap.xml mit Unter-Sitemaps unter /sitemap/0.xml, /sitemap/1.xml etc. Für sehr große Kataloge lohnt es sich, das Zusammenspiel mit Partial Prerendering zu prüfen, damit die Sitemap-Generierung nicht die Build-Zeit dominiert.

JSON-LD Structured Data für Rich Results einbinden

JSON-LD ist der von Google empfohlene Weg, strukturierte Daten wie Article-, Product- oder FAQPage-Schemas an Suchmaschinen zu übergeben. Laut Googles Search-Central-Dokumentation ist es die einzige Form, die für neue Rich-Result-Typen zuverlässig ausgewertet wird. Die Metadata API selbst hat kein natives structured-data-Feld, weil JSON-LD als Body-Content im HTML platziert wird. Der App-Router-Weg ist deshalb, das Script direkt in der Server Component zu rendern.

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

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: [`https://nextjslaunchpad.com/blog/${slug}/opengraph-image`],
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.author.name,
      url: `https://nextjslaunchpad.com/authors/${post.author.slug}`,
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://nextjslaunchpad.com/blog/${slug}`,
    },
  }

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

Next.js belässt das Script unverändert im finalen HTML. React entfernt dangerouslySetInnerHTML nicht und escaped auch die Content-Type-Zeichenkette nicht. Testen Sie das Ergebnis anschließend mit dem Rich-Results-Test von Google. Ein einzelner fehlender Pflicht-Property (z. B. image bei BlogPosting) führt zu einer stummen Ablehnung, das habe ich beim ersten Deploy meines eigenen Blogs erst zwei Wochen später bemerkt, als der Rich Result plötzlich nicht mehr auftauchte.

Warum wird meine Metadata nicht angezeigt? Häufige Fehler beheben

Wenn Meta-Tags trotz korrekter Konfiguration nicht im HTML landen, liegt's in gefühlt 90 % der Fälle an einer dieser vier Ursachen: die Datei ist eine Client Component ('use client'), das Export-Objekt heißt anders als metadata, die Datei liegt außerhalb von app/, oder ein Parent-Layout überschreibt einen Wert, den Sie später setzen wollen. Die folgende Tabelle fasst die häufigsten Ursachen und Lösungen zusammen, sortiert nach der Frequenz, mit der ich sie in echten Projekten sehe:

SymptomWahrscheinliche UrsacheLösung
Meta-Tags fehlen komplett Datei enthält 'use client' Metadata nur aus Server Components exportieren; UI in Kind-Komponente auslagern.
Titel wird nicht überschrieben Parent-Layout nutzt title.absolute Absolute-Titel im Parent entfernen oder in der Kindseite ebenfalls absolute setzen.
OG-Bilder als "undefined" metadataBase im Root fehlt Im Root-Layout metadataBase: new URL('https://…') definieren.
Slack/LinkedIn zeigen alten Preview Externer Scraper-Cache Slack: „unfurl again"-Link; LinkedIn: Post Inspector; Facebook: Sharing Debugger neu scrapen lassen.
Metadata ändert sich nicht bei Navigation Layout überschreibt sie generateMetadata in die tiefste Route verlagern, nicht ins Layout.
Warnung „unsupported metadata field" Feld gehört in ein anderes Segment viewport und themeColor aus separatem viewport-Export ausgeben.

Ein häufig übersehener Fall: Seit Next.js 14.2 sind viewport, themeColor und colorScheme aus dem metadata-Export in einen eigenen viewport-Export migriert. Wenn Sie sie weiterhin unter metadata deklarieren, wird eine Deprecation-Warnung im Build geloggt und die Werte werden ignoriert. In Next.js 16 wird die alte Position entfernt.

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

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

Metadata mit Streaming, PPR und Caching kombinieren

Ein subtiler Performance-Fallstrick der Metadata API, der mich mal eine ganze Debugging-Session gekostet hat: generateMetadata muss vollständig aufgelöst sein, bevor Next.js den ersten Byte des HTML-Streams senden kann. Wenn Ihre Metadata-Funktion also 800 ms lang eine Datenbank-Query wartet, blockiert das jede Streaming- oder Suspense-Optimierung im Page-Body. Für Seiten, bei denen SEO-Titel und -Description wirklich datengetrieben sein müssen, ist das ein akzeptabler Trade-off. Für Seiten mit generischen Metadata-Werten sollten Sie den statischen Export vorziehen.

Im Zusammenspiel mit dem Cache-Components-Modell von Next.js 16 gilt: Wrappen Sie Datenzugriffe, die generateMetadata und Page teilen, in eine 'use cache'-Funktion mit passendem cacheTag. So bleibt der Request in der Static Shell auslieferbar, und bei einem revalidateTag()-Call wird sowohl Metadata als auch Body neu erzeugt – ohne Inkonsistenzen zwischen <title> und Article-Body.

// lib/posts.ts
'use cache'
import { unstable_cacheTag as cacheTag } from 'next/cache'

export async function getPostBySlug(slug: string) {
  cacheTag(`post:${slug}`)
  return db.post.findUnique({ where: { slug } })
}

Für Blogs mit tausenden Einträgen empfiehlt sich, generateStaticParams zu ergänzen, damit Next.js beim Build eine Liste der zu prerendernden Slugs bekommt. Die Metadata wird dann pro Slug einmalig zur Build-Zeit erzeugt und bis zum nächsten Revalidate ausgeliefert – kein Datenbank-Roundtrip pro Request. In Kombination mit Partial Prerendering läuft der statische Shell inklusive Metadata direkt vom CDN, während dynamische Bereiche wie Kommentare oder Like-Counts nachgestreamt werden.

Häufig gestellte Fragen

Kann ich generateMetadata in einer Client Component verwenden?

Nein. Sowohl metadata als auch generateMetadata funktionieren ausschließlich in Server Components. Sobald eine Datei mit 'use client' markiert ist, ignoriert Next.js die Exports stillschweigend und rendert nur die im Root-Layout definierte Fallback-Metadata.

Wie überschreibe ich Metadata in einem Layout für eine einzelne Seite?

Definieren Sie metadata oder generateMetadata direkt in der tiefsten page.tsx. Werte aus tieferen Segmenten haben Vorrang. Für den Titel nutzen Sie title: { absolute: '…' }, um das Template des Parent-Layouts zu umgehen.

Werden Metadata-Fetches doppelt ausgeführt, wenn ich sie in Page und generateMetadata verwende?

Nein, sofern Sie fetch() mit identischer URL oder eine mit react.cache() gewrappte Funktion verwenden. Next.js dedupliziert diese Aufrufe innerhalb eines Requests automatisch. Rohes db.query() ohne Cache-Wrapper wird hingegen zweimal ausgeführt.

Wie generiere ich eine dynamische robots.txt und sitemap.xml?

Legen Sie app/robots.ts und app/sitemap.ts an. Beide Dateien exportieren eine Standard-Funktion, die ein typisiertes Objekt (MetadataRoute.Robots bzw. MetadataRoute.Sitemap) zurückgibt. Next.js erzeugt daraus automatisch die passenden Endpunkte mit korrekten Content-Types.

Braucht man metadataBase im Root-Layout?

Ja, für alle Sites, die Open-Graph-Bilder oder canonical URLs mit relativen Pfaden nutzen. Ohne metadataBase gibt Next.js im Build eine Warnung aus und lässt relative URLs im finalen HTML weg, was Preview-Bilder in Social Media unbrauchbar macht.

Editorial Team
Über den Autor Editorial Team

Our team of expert writers and editors.