From a8990a872abcd44a2ada9ea5b9936b86c840fd26 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 20 Sep 2026 03:05:54 +0200 Subject: [PATCH] fix: Warenkorb, Kasse, Konto und Rechnungen fielen nicht aus dem Suchindex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit robots.txt sperrte /checkout, /warenkorb, /konto und /rechnung fuer Crawler. Das verbietet aber nur das Abrufen, nicht das Indexieren: verweist irgendwo ein Link auf eine dieser Seiten, nimmt Google die URL trotzdem auf — als inhaltslosen Treffer mit dem Hinweis "Fuer diese Seite sind keine Informationen verfuegbar". Der uebliche Ausweg, ein noindex-Meta-Tag, half hier nicht: eine per robots.txt gesperrte Seite wird nie geladen, also sieht der Crawler das Tag nie. Die Sperre verhinderte ausgerechnet das Mittel, das die URLs wieder aus dem Index geholt haette. Keine dieser Seiten trug bisher ueberhaupt ein noindex. Jetzt greifen beide Mechanismen zusammen statt gegeneinander: - lib/seo.ts buendelt die beiden robots-Bausteine. noIndex laesst Links weiterhin folgen (Footer- und Produktlinks im Warenkorb sollen ihre Linkkraft behalten), noIndexPrivate unterbindet zusaetzlich Cache-Kopie, Bildindexierung und Textausschnitt. - Kasse, Bestellbestaetigung und Rechnungsansicht bekommen noIndexPrivate, der Warenkorb noIndex. - Der gesamte Kontobereich bekommt das Tag einmal ueber ein neues konto/layout.tsx statt in zehn Unterseiten einzeln. Metadata erbt feldweise, ein eigener title in einer Unterseite loescht den Wert also nicht. - robots.txt sperrt nur noch /api/ und /admin — also das, was ein Crawler wirklich nie abrufen soll. Alles andere darf gecrawlt werden, damit das noindex ueberhaupt gelesen wird. /uploads bleibt bewusst offen: dort liegen die Produktbilder, die in der Bildersuche erwuenscht sind. Zusaetzlich eine ausdrueckliche Regel fuer GPTBot, ClaudeBot, PerplexityBot und Google-Extended (oeffentliche Inhalte ja, interne Bereiche nein) sowie die host-Angabe in robots.txt. tsc: 0 Fehler. vitest: 359/359 gruen. eslint: 0 Warnungen. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/checkout/page.tsx | 5 ++- src/app/checkout/success/layout.tsx | 5 ++- src/app/konto/layout.tsx | 19 +++++++++++ src/app/rechnung/[invoiceNumber]/page.tsx | 5 ++- src/app/robots.ts | 32 ++++++++++++++++++- src/app/warenkorb/page.tsx | 5 ++- src/lib/seo.ts | 39 +++++++++++++++++++++++ 7 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 src/app/konto/layout.tsx create mode 100644 src/lib/seo.ts diff --git a/src/app/checkout/page.tsx b/src/app/checkout/page.tsx index b5fb903..8b561c7 100644 --- a/src/app/checkout/page.tsx +++ b/src/app/checkout/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { noIndexPrivate } from "@/lib/seo"; import { getCurrentCustomer } from "@/lib/auth"; import { getAddressesForCustomer } from "@/lib/account-data"; import { getShippingTiers, getShippingRegion } from "@/lib/shipping"; @@ -16,7 +17,9 @@ export const dynamic = "force-dynamic"; export async function generateMetadata(): Promise { const t = getDictionary(await getLocale()); - return { title: t.checkoutPage.eyebrow }; + // Die Kasse traegt Adress- und Zahlungsdaten und soll nie als + // Suchtreffer erscheinen. + return { title: t.checkoutPage.eyebrow, robots: noIndexPrivate }; } export default async function CheckoutPage() { diff --git a/src/app/checkout/success/layout.tsx b/src/app/checkout/success/layout.tsx index 1288f93..3287bba 100644 --- a/src/app/checkout/success/layout.tsx +++ b/src/app/checkout/success/layout.tsx @@ -1,10 +1,13 @@ import type { Metadata } from "next"; +import { noIndexPrivate } from "@/lib/seo"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; export async function generateMetadata(): Promise { const t = getDictionary(await getLocale()); - return { title: t.checkoutSuccessPage.successEyebrow }; + // Die Bestaetigungsseite zeigt Bestelldaten und ist ueber eine + // Bestell-ID in der URL erreichbar — nicht indexieren. + return { title: t.checkoutSuccessPage.successEyebrow, robots: noIndexPrivate }; } export default function CheckoutSuccessLayout({ children }: { children: React.ReactNode }) { diff --git a/src/app/konto/layout.tsx b/src/app/konto/layout.tsx new file mode 100644 index 0000000..ec88976 --- /dev/null +++ b/src/app/konto/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from "next"; +import { noIndexPrivate } from "@/lib/seo"; + +// Der Kontobereich enthaelt ausschliesslich personenbezogene Seiten +// (Bestellungen, Adressen, Wunschliste, Zwei-Faktor-Einrichtung) sowie die +// Anmeldung selbst. Nichts davon gehoert in den Suchindex. +// +// Gesetzt einmal hier im Layout statt in jeder Unterseite: /konto hat aktuell +// zehn Routen, und die naechste vergisst man sonst garantiert. Next.js +// vererbt robots aus dem Layout an alle Unterseiten; setzt eine Unterseite +// eigene Metadata (z.B. nur einen title), bleibt der geerbte robots-Wert +// erhalten, weil Metadata feldweise und nicht als Ganzes ueberschrieben wird. +export const metadata: Metadata = { + robots: noIndexPrivate, +}; + +export default function KontoLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/src/app/rechnung/[invoiceNumber]/page.tsx b/src/app/rechnung/[invoiceNumber]/page.tsx index d70b93a..6a9b039 100644 --- a/src/app/rechnung/[invoiceNumber]/page.tsx +++ b/src/app/rechnung/[invoiceNumber]/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { noIndexPrivate } from "@/lib/seo"; import { notFound, redirect } from "next/navigation"; import { Container } from "@/components/ui"; import { BackLink } from "@/components/back-link"; @@ -10,7 +11,9 @@ import { invoiceVatBreakdown } from "@/lib/vat"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; -export const metadata: Metadata = { title: "Rechnung" }; +// Rechnungen enthalten Name, Anschrift und Kaufhistorie. noIndexPrivate +// unterbindet zusaetzlich Cache-Kopie und Snippet (siehe lib/seo.ts). +export const metadata: Metadata = { title: "Rechnung", robots: noIndexPrivate }; export const dynamic = "force-dynamic"; export default async function InvoicePage(props: PageProps<"/rechnung/[invoiceNumber]">) { diff --git a/src/app/robots.ts b/src/app/robots.ts index bf969bb..b53ae35 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -9,15 +9,45 @@ import { getSiteUrl } from "@/lib/url"; // egal unter welcher Domain die Seite spaeter tatsaechlich lief. export const dynamic = "force-dynamic"; +// Nur Pfade, die ein Crawler ueberhaupt nicht ABRUFEN soll. Das ist eine +// bewusst kurze Liste, denn "Disallow" und "noindex" schliessen einander aus: +// eine per robots.txt gesperrte Seite wird nie geladen, also sieht Google das +// dort gesetzte noindex nie — und nimmt die URL bei einem eingehenden Link +// trotzdem als inhaltslosen Treffer in den Index auf. +// +// Deshalb stehen /checkout, /warenkorb, /konto und /rechnung hier NICHT mehr +// (frueher taten sie es). Sie tragen stattdessen ein noindex-Meta-Tag (siehe +// lib/seo.ts) und duerfen dafuer gecrawlt werden — nur so faellt eine bereits +// indexierte URL wieder aus dem Index. Der zusaetzliche Crawl-Aufwand ist bei +// einer Handvoll Seiten vernachlaessigbar. +// +// Was bleibt: +// - /api: Maschinenschnittstellen ohne jeden Nutzen in Suchergebnissen. +// - /admin: liefert einem anonymen Crawler ohnehin nur eine Weiterleitung auf +// die Anmeldung; jeder Abruf ist reine Last. +// - /uploads ist bewusst NICHT gesperrt — dort liegen die Produktbilder, die +// in der Google-Bildersuche durchaus erwuenscht sind. +const disallow = ["/api/", "/admin"]; + export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: "/", - disallow: ["/admin", "/checkout", "/warenkorb", "/api", "/konto", "/rechnung"], + disallow, + }, + // GPTBot & Co. duerfen die oeffentlichen Inhalte lesen (Sichtbarkeit in + // KI-Antworten ist erwuenscht), aber ohne die internen Bereiche. + // Separat aufgefuehrt, damit eine spaetere Entscheidung dagegen nur + // diesen Block betrifft und nicht die Suchmaschinen mit einschliesst. + { + userAgent: ["GPTBot", "ClaudeBot", "PerplexityBot", "Google-Extended"], + allow: "/", + disallow, }, ], sitemap: `${getSiteUrl()}/sitemap.xml`, + host: getSiteUrl(), }; } diff --git a/src/app/warenkorb/page.tsx b/src/app/warenkorb/page.tsx index cffbf20..0352fb0 100644 --- a/src/app/warenkorb/page.tsx +++ b/src/app/warenkorb/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from "next"; +import { noIndex } from "@/lib/seo"; import { getShippingTiers } from "@/lib/shipping"; import { getActiveProducts } from "@/lib/data"; import { getBulkDiscountTiers } from "@/lib/bulk-discount"; @@ -12,7 +13,9 @@ export const dynamic = "force-dynamic"; export async function generateMetadata(): Promise { const t = getDictionary(await getLocale()); - return { title: t.cart.title }; + // Ein Warenkorb ist pro Besucher verschieden und als Suchtreffer wertlos — + // siehe Begruendung in lib/seo.ts, warum robots.ts allein nicht genuegt. + return { title: t.cart.title, robots: noIndex }; } export default async function WarenkorbPage() { diff --git a/src/lib/seo.ts b/src/lib/seo.ts new file mode 100644 index 0000000..68c1a63 --- /dev/null +++ b/src/lib/seo.ts @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; + +/** + * Metadata-Bausteine fuer Seiten, die NICHT in den Suchindex gehoeren. + * + * Warum reicht der Eintrag in robots.ts nicht? + * "Disallow" verbietet nur das CRAWLEN, nicht das INDEXIEREN. Verweist + * irgendwo ein Link auf /warenkorb oder /konto/bestellungen, nimmt Google die + * URL trotzdem in den Index auf — nur eben ohne Inhalt, als Treffer mit dem + * Hinweis "Fuer diese Seite sind keine Informationen verfuegbar". Schlimmer: + * weil der Crawler die Seite nicht lesen darf, sieht er ein dort gesetztes + * noindex NIE. Deshalb muessen beide Mechanismen zusammenspielen — robots.ts + * haelt den Crawler von den nutzlosen Seiten fern, dieses Meta-Tag sorgt + * dafuer, dass eine trotzdem entdeckte URL wieder aus dem Index faellt. + * + * follow: true statt false ist Absicht. Links auf diesen Seiten (Footer, + * Navigation, Produktlinks im Warenkorb) sollen ihre Linkkraft weiterhin an + * die oeffentlichen Seiten weitergeben; nur die Seite selbst soll nicht als + * Suchtreffer erscheinen. + */ +export const noIndex: Metadata["robots"] = { + index: false, + follow: true, + googleBot: { index: false, follow: true }, +}; + +/** + * Wie noIndex, zusaetzlich ohne Cache-Kopie und ohne Textausschnitt in den + * Suchergebnissen. Fuer Seiten mit personenbezogenen Daten (Rechnungen, + * Bestellungen, Kontodaten): selbst wenn eine URL versehentlich oeffentlich + * erreichbar waere, soll kein Inhalt in einem Suchmaschinen-Snippet oder + * -Cache landen. + */ +export const noIndexPrivate: Metadata["robots"] = { + index: false, + follow: false, + nocache: true, + googleBot: { index: false, follow: false, noimageindex: true, "max-snippet": 0 }, +};