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