Artikel

SEO in Next.js: Metadata, Sitemap, Robots und strukturierte Daten

Die Mechanik von SEO im App Router: generateMetadata, Canonical-Tags, das korrekte Setzen von lastModified in der Sitemap, JSON-LD mit @graph strukturieren und welche Rich Results heute tatsächlich noch leben.

Furkan Özay2. September 2026 · 6 Min. Lesezeit
czCZAY.DEV // EDITORIALDE
MODERN WEB & REACT

#Web Temelleri · 6 MIN LESEZEIT

FURKAN ÖZAYVOL. 2026 // #2A05

SEO in Next.js heißt nicht, Plugins zu installieren – es sind nur ein paar Dateien und ein paar Funktionen. Es kann aber verwirrend sein, herauszufinden, was wofür zuständig ist, und die meisten Anleitungen im Netz enthalten veraltete Infos.

Dieser Beitrag geht die SEO-Bausteine im App Router einzeln durch. Alle Beispiele stammen aus dem echten Code, der genau auf dieser Seite läuft.

1. Metadata: statisch und generiert

Es gibt zwei Varianten. Für statische Seiten das metadata-Objekt:

TypeScript
// app/studio/page.tsx
export const metadata: Metadata = {
	title: "Czay Studio",
	description: "Work I've delivered for companies and brands…",
	alternates: { canonical: "https://czay.dev/studio" },
	openGraph: {
		title: "Czay Studio | Furkan Özay",
		description: "…",
		url: "https://czay.dev/studio",
	},
};

Für dynamische Seiten 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,
		},
	};
}

Warnung: Vergiss modifiedTime nicht

Diese Zeile habe ich beim Schreiben dieses Beitrags ergänzt. Die Seite hatte publishedTime, aber kein modifiedTime: Beiträge, die ich aktualisiert hatte, sahen beim Teilen und in Browsern weiterhin wie ihre ursprüngliche Version aus. Wenn du deine Beiträge regelmäßig aktualisierst, ist dieses Feld das günstigste Signal, um Aktualität zu vermitteln.

Root-Template

Wenn du in der layout.tsx ein Titel-Template definierst, musst du deinen Markennamen nicht auf jeder einzelnen Seite neu schreiben:

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

metadataBase ist besonders wichtig: Ohne sie werden relative Open-Graph-Bild-URLs nicht zu absoluten URLs aufgelöst, und deine Social-Sharing-Karten bleiben leer.

2. Canonical: Das am häufigsten übersehene Feld

Jede Seite sollte ihre eigene Canonical-URL deklarieren. Fehlt sie, werden verschiedene Pfade zum selben Inhalt (Tracking-Parameter, abschließende Schrägstriche, die www-Variante) als getrennte Seiten behandelt, und deine SEO-Signale werden verteilt.

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

Noch kritischer ist das, wenn du den Filterzustand deines Blogs in der Adressleiste hältst: Eine Liste, die mit ?topic=nextjs geladen wird, ist keine eigene Seite, sondern nur eine Ansicht derselben Seite.

3. sitemap.ts und robots.ts

Beides sind einfach Dateien; rohes XML schreibst du nicht mehr.

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",
	};
}

Achtung: Befülle lastModified nicht mit dem Veröffentlichungsdatum

Auch das war ein Fehler, den ich beim Schreiben dieses Beitrags entdeckt und behoben habe: Die Sitemap nutzte new Date(post.date) und ignorierte damit das Feld updated komplett. Ich hatte sechs Beiträge aktualisiert, und die Sitemap lieferte sie weiterhin mit den alten Daten aus – das Recrawl-Signal ging verloren.

So ist es richtig: new Date(post.updated ?? post.date)

Achte außerdem darauf, welche Seiten tatsächlich in deine Sitemap kommen. Auf dieser Seite sollen Service-Detailseiten einzeln gefunden werden; sie wegzulassen würde diesen Zweck zunichtemachen:

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. Strukturierte Daten (JSON-LD)

Next.js hat dafür keine eingebaute API; du renderst das Script-Tag selbst.

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 zu verwenden ist entscheidend: Wenn du den Text manuell zusammensetzt, zerstören Anführungszeichen und <-Zeichen in deinem Inhalt das Schema.

Mehrere Schemas mit @graph aufbauen

Wenn du auf einer Seite mehrere Entitäten definieren musst, nutze statt mehrerer Script-Tags einen einzigen @graph. So können sich die Entitäten per @id gegenseitig referenzieren:

TypeScript
{
	"@context": "https://schema.org",
	"@graph": [
		{ "@type": "Service", "@id": `${url}#service`, name: "Mobile app", /* … */ },
		{ "@type": "BreadcrumbList", "@id": `${url}#breadcrumb`, /* … */ },
		{ "@type": "FAQPage", "@id": `${url}#faq`, /* … */ },
	],
}

Wichtig: Welche Rich Results leben heute tatsächlich noch?

Hier kursieren viele veraltete Ratschläge. So sieht die aktuelle Lage aus:

  • BreadcrumbList – Funktioniert weiterhin; zeigt die Hierarchie „Seite › Bereich › Unterseite“ in den Suchergebnissen an. Einfach umzusetzen, klarer Nutzen.
  • Article / BlogPosting – Erzeugt keine Rich-Snippet-Box, macht aber Datumsangaben, Autor und Änderungsinfos für den Crawler sichtbar.
  • FAQPage – Google hat FAQ-Rich-Results im August 2023 auf autoritative Seiten beschränkt und sie am 7. Mai 2026 vollständig eingestellt. Das Markup ist weiterhin gültig und wird von Nicht-Google-Konsumenten gelesen, aber eine Snippet-Box bei Google zu erwarten ist inzwischen ein Fehler.

Der Rat „Lass uns ein FAQ-Schema einbauen, um Rich Results abzugreifen“ ist also überholt. Nutze strukturierte Daten, um deine Seite zu beschreiben, nicht um Snippets zu jagen.

Packe keine unsichtbaren Inhalte ins Schema

Strukturierte Daten beschreiben den tatsächlichen Inhalt der Seite. Ein FAQ ins Schema aufzunehmen, das auf der gerenderten Seite nicht existiert, verstößt gegen die Richtlinien der Suchmaschinen und kann zu Abstrafungen führen.

5. Statische Generierung und generateStaticParams

Die unsichtbare Hälfte von SEO ist Geschwindigkeit. Schreibst du das für dynamische Routen nicht, wird jede Seite bei jeder einzelnen Anfrage neu gebaut:

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

Dasselbe gilt für Service-Detailseiten: Bei einer endlichen, bekannten Liste sollten alle zur Build-Zeit vorgerendert werden.

6. Open-Graph-Bilder

Next.js kann Bilder per Code dynamisch erzeugen; du musst nicht für jeden Beitrag manuell ein Bild gestalten:

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

Diese Datei gibt eine React-Komponente zurück, und Next.js rendert sie zu einem Bild mit 1200×630. Titel, Tags und Lesezeit kannst du direkt einsetzen.

7. RSS ist weiterhin wertvoll

Einen Feed mit app/rss.xml/route.ts zu erzeugen, braucht nur ein paar Zeilen Code. Auch wenn die Leserzahlen niedrig wirken, ist RSS der einfachste Weg, deine Inhalte an Aggregatoren und Newsletter auszuspielen – und diese eingehenden Links sind echt.

Checkliste

Zusammenfassung: Vor dem Livegang

  • Gibt es in der Root-layout.tsx eine metadataBase und ein Titel-Template?
  • Ist alternates.canonical für jede Seite gesetzt?
  • Liefert generateMetadata auf dynamischen Seiten publishedTime und modifiedTime?
  • Ist lastModified in sitemap.ts wirklich das Datum der letzten Aktualisierung?
  • Sind die Seiten, die einzeln in den Suchergebnissen erscheinen sollen, in der Sitemap enthalten?
  • Hast du generateStaticParams für dynamische Routen geschrieben?
  • Wird JSON-LD mit JSON.stringify gerendert?
  • Existiert alles, was im Schema deklariert ist, tatsächlich auf der Seite?
  • Deklariert robots.ts die Sitemap?

Zwei Punkte dieser Liste habe ich beim Schreiben dieses Beitrags auf meiner eigenen Seite behoben. Das Schwierige an SEO ist nicht, etwas nicht zu wissen, sondern nach der Einrichtung nicht mehr nachzuschauen.

Verwandter Beitrag: MDX mit dem App Router: der richtige Weg, einen Blog zu bauen

Interaktive Roadmap & Tool

Als Nächstes lesen