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:
// 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:
// 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:
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.
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.
// 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",
};
}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:
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.
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:
{
"@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:
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:
app/blog/[slug]/opengraph-image.tsxDiese 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.tsxeinemetadataBaseund ein Titel-Template? - Ist
alternates.canonicalfür jede Seite gesetzt? - Liefert
generateMetadataauf dynamischen SeitenpublishedTimeundmodifiedTime? - Ist
lastModifiedinsitemap.tswirklich das Datum der letzten Aktualisierung? - Sind die Seiten, die einzeln in den Suchergebnissen erscheinen sollen, in der Sitemap enthalten?
- Hast du
generateStaticParamsfür dynamische Routen geschrieben? - Wird JSON-LD mit
JSON.stringifygerendert? - Existiert alles, was im Schema deklariert ist, tatsächlich auf der Seite?
- Deklariert
robots.tsdie 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
Junior Backend Developer 2026
Was du wirklich wissen musst, um 2026 eine Junior-Backend-Stelle zu bekommen, und was warten kann – ohne die Buzzword-Fallen aus echten Stellenanzeigen.
SQL oder NoSQL?
Beantworte sechs Fragen, und ich sage dir, welche Art von Datenbank zu deinem Projekt passt und warum.