Canonicals und Sitemaps, die nicht auseinanderdriften können
Ihre Sitemap listet Routen, die Ihr Router nicht mehr ausliefert, und Ihre Canonicals widersprechen beiden. Die Lösung ist strukturell — alle drei aus einer Quelle ableiten.
Drei Dinge beschreiben einem Crawler die URLs Ihrer Site: die Routen, die Ihre Anwendung tatsächlich ausliefert, das Canonical-Tag auf jeder Seite und Ihre Sitemap. In den meisten Codebasen werden diese an drei verschiedenen Orten, von drei verschiedenen Personen, zu drei verschiedenen Zeitpunkten gepflegt.
Sie driften auseinander. Sie driften immer.
Wie Drift aussieht
- Die Sitemap listet
/blog/old-post, das 404 liefert. /pricing/und/pricinggeben beide 200 zurück, ohne Canonical dazwischen.- Das Canonical der deutschen Seite zeigt auf die englische URL, weil jemand den Metadaten-Block kopiert hat.
- Eine neue Route ging vor drei Monaten live und steht in gar keiner Sitemap.
- Die Sitemap enthält
https://www.example.com/..., während die Sitehttps://example.com/...ausliefert.
Nichts davon ist dramatisch. Zusammen bedeuten sie, dass der Crawler sein Budget für URLs ausgibt, die Ihnen egal sind, und Ranking-Signale auf Duplikate aufteilt.
Eine Quelle, drei Ausgaben
Die strukturelle Lösung: Machen Sie die URL zu einem berechneten Wert und schreiben Sie nie eine von Hand.
// site.config.ts — the only place a URL is constructed
export function absoluteUrl(path = '/'): string {
return `${siteConfig.url}${path.startsWith('/') ? path : `/${path}`}`;
}
/**
* Locale-aware pathname. `en` is the default locale and is served without a
* prefix, so its canonical URLs stay at the site root.
*/
export function localizedPath(locale: Locale, path = '/'): string {
const clean = path === '/' ? '' : path.startsWith('/') ? path : `/${path}`;
return locale === defaultLocale ? clean || '/' : `/${locale}${clean}`;
}
export function localizedUrl(locale: Locale, path = '/'): string {
return absoluteUrl(localizedPath(locale, path));
}Jedes Canonical, jeder Sitemap-Eintrag und jede hreflang-Alternate ruft
localizedUrl auf. Ändern Sie die Domain in einer Konstante, und alle drei
folgen. Es gibt keine Gelegenheit, dass ein Schrägstrich am Ende in einer
Ausgabe auftaucht und in einer anderen nicht, weil die Zeichenkette in genau
einer Funktion gebaut wird.
Machen Sie das Canonical unmöglich vergessbar
Eine Seite sollte nicht ohne Canonical ausgeliefert werden können. Durchsetzbar wird das, indem Metadaten durch einen Helfer laufen, dessen Pflichtargumente alles enthalten, was ein Canonical braucht:
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale, slug } = await params;
const doc = getDoc('blog', locale, slug);
if (!doc) return {};
return buildMetadata({
locale,
title: doc.title,
description: doc.description,
path: `/blog/${slug}`, // canonical is derived from this
availableLocales: doc.availableLocales,
type: 'article',
});
}Wer eine Route hinzufügt, schreibt path, weil die Funktion sonst nicht durch
den Typecheck kommt. Man kann das Canonical nicht vergessen und seine Form
nicht falsch treffen, weil man nie eine URL schreibt.
Die Sitemap liest dieselbe Quelle wie die Seiten
Next.js erzeugt sitemap.xml aus app/sitemap.ts. Der Fehler ist, dort ein
statisches Array hinzuschreiben.
export default function sitemap(): MetadataRoute.Sitemap {
const entries: MetadataRoute.Sitemap = [];
for (const collection of ['services', 'blog'] as const) {
for (const locale of locales) {
// Same helper the pages render from — the two cannot disagree.
for (const doc of getDocs(collection, locale)) {
const path = `/${collection}/${doc.slug}`;
entries.push({
url: localizedUrl(locale, path),
lastModified: doc.updated ? new Date(doc.updated) : new Date(doc.date),
alternates: alternatesFor(path, availableLocalesFor(collection, doc.slug)),
});
}
}
}
return entries;
}Nehmen Sie einen Artikel vom Netz, und er verlässt die Sitemap beim nächsten Build. Fügen Sie eine Übersetzung hinzu, und die Alternates wachsen. Kein Pflegeschritt, weil die Sitemap kein Dokument ist — sie ist eine Projektion.
lastModified ist eine Behauptung, keine Formalie
lastModified für jeden Eintrag auf new Date() zu setzen, teilt dem Crawler
mit, dass sich Ihre ganze Site heute geändert hat, und das jeden Tag. Nach ein
paar Zyklen glaubt er Ihnen nicht mehr. Nehmen Sie das echte
Änderungsdatum des Dokuments.
Schrägstrich am Ende: entscheiden Sie sich und stehen Sie dazu
Next.js liefert standardmäßig ohne abschließenden Schrägstrich aus. Das ist in Ordnung. Nicht in Ordnung ist, beides auszuliefern.
// next.config.ts
const nextConfig: NextConfig = {
trailingSlash: false, // the default; state it so nobody 'fixes' it later
};Bestätigen Sie dann, dass die andere Form weiterleitet statt aufzulösen:
curl -sI https://example.com/pricing/ | head -1
# HTTP/2 308 ← correct
# HTTP/2 200 ← two URLs for one pageDieselbe Prüfung gilt für www gegen Apex und für http gegen https. Jedes
sollte eine einzige dauerhafte Weiterleitung sein, keine zweite lebende Kopie.
Robots: indexieren Sie Ihre Previews nicht
Jedes Branch-Deployment ist eine vollständige Kopie Ihrer Site unter einer öffentlichen URL. Ist sie indexierbar, ist sie ein Duplikat Ihrer Produktions-Site unter anderem Hostnamen.
export default function robots(): MetadataRoute.Robots {
// Preview and branch deployments must never be indexed.
const isProduction =
process.env.VERCEL_ENV === 'production' ||
process.env.NEXT_PUBLIC_SITE_URL === siteConfig.url;
if (!isProduction) {
return { rules: [{ userAgent: '*', disallow: '/' }] };
}
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
sitemap: absoluteUrl('/sitemap.xml'),
host: siteConfig.url,
};
}Beachten Sie, dass das auch metadataBase repariert. Ein Preview-Deployment,
das die Produktions-URL erbt, gibt Produktions-Canonicals von einem
Staging-Host aus — was harmlos ist. Eines, das seinen eigenen Hostnamen in
Canonicals ausgibt und dabei indexierbar ist, richtet aktiv Schaden an.
Eine Fünf-Minuten-Prüfung
# Canonical, on the page the crawler sees — not the inspector's hydrated DOM
curl -s https://example.com/de/blog/some-post | grep -E 'canonical|alternate'
# Sitemap parses, and the count matches what you expect
curl -s https://example.com/sitemap.xml | grep -c '<loc>'
# Every URL in the sitemap returns 200
curl -s https://example.com/sitemap.xml \
| grep -oP '(?<=<loc>)[^<]+' \
| xargs -P8 -I{} sh -c 'printf "%s %s\n" "$(curl -o /dev/null -sw "%{http_code}" "{}")" "{}"' \
| grep -v '^200'Der letzte Befehl sollte nichts ausgeben. Gibt er etwas aus, schickt Ihre Sitemap Crawler auf Seiten, die es nicht gibt — und sie wird das weiter tun, bis sich strukturell etwas daran ändert, wie die Sitemap entsteht.
Die strukturelle Änderung ist der ganze Punkt. Korrektes SEO ist keine Liste von Tags, an die man denken muss. Es ist eine Architektur, in der das falsche Tag nicht geschrieben werden kann.
