Zum Inhalt springen

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.

4 Min. Lesezeit

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 /pricing geben 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 Site https://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 page

Dieselbe 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.

Zurück zu allen Artikeln