From d3020cd4da696ec11f396526f6c11130b5dc2b4e Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 15 Sep 2026 10:00:59 +0200 Subject: [PATCH] fix: Sitemap, Canonicals und JSON-LD folgen der echten Domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Adresse der Seite war an zwei Stellen gepflegt: E-Mails nutzten NEXT_PUBLIC_SITE_URL, alles SEO-Relevante dagegen ein fest verdrahtetes "https://" + siteConfig.domain (16 Stellen, darunter eine roh eingetippte "https://wabenhain.com" im Produkt-JSON-LD). Laeuft der Shop unter einer anderen Domain als wabenhain.com — Staging, Vorschau-Host, spaeterer Domainwechsel, der im k8s-Ingress hinterlegte Host — zeigten damit sitemap.xml, robots.txt, saemtliche Canonical-Tags und alle strukturierten Daten geschlossen auf eine fremde Adresse. Google verwirft eine Sitemap mit fremden Hosts, und ein Canonical auf eine andere Domain weist die Indexierung aktiv von der echten Seite weg. Jetzt eine einzige Quelle: getSiteUrl() in lib/url.ts, mit siteConfig.domain als Rueckfallebene, damit eine vergessene Umgebungsvariable nicht "http://localhost:3000" in einer oeffentlichen Sitemap landen laesst. getBaseUrl() bleibt als Alias fuer den in E-Mail-Code eingebuergerten Namen und liefert denselben Wert. robots.ts bekommt dabei "force-dynamic" (wie sitemap.ts es schon hatte): ohne das rendert Next.js die Datei beim Build statisch vor und backt den Wert ein, den NEXT_PUBLIC_SITE_URL genau dann hatte. Im Docker-Build ist die Variable nicht gesetzt — die Sitemap-Zeile waere also dauerhaft auf dem Rueckfallwert eingefroren. Gegen einen laufenden Produktions-Build geprueft: robots.txt, sitemap.xml, Canonical-Tags und JSON-LD folgen jetzt alle dem zur Laufzeit gesetzten Wert. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/honig/[slug]/page.tsx | 13 +++++----- src/app/layout.tsx | 13 +++++----- src/app/robots.ts | 12 +++++++-- src/app/shop/page.tsx | 7 +++--- src/app/sitemap.ts | 5 ++-- src/app/wissen/[slug]/page.tsx | 5 ++-- src/lib/url.test.ts | 46 ++++++++++++++++++++++++++++++++++ src/lib/url.ts | 36 +++++++++++++++++++++++--- 8 files changed, 112 insertions(+), 25 deletions(-) create mode 100644 src/lib/url.test.ts diff --git a/src/app/honig/[slug]/page.tsx b/src/app/honig/[slug]/page.tsx index 1316161..e57b9d3 100644 --- a/src/app/honig/[slug]/page.tsx +++ b/src/app/honig/[slug]/page.tsx @@ -13,7 +13,7 @@ import { TrustBadges } from "@/components/trust-badges"; import { StickyAddToCartBar } from "@/components/sticky-add-to-cart-bar"; import { getProductBySlug, getRelatedProducts } from "@/lib/data"; import { formatPrice, formatPricePerKg, formatDate } from "@/lib/format"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getCompanyInfo } from "@/lib/company-info"; import { getCurrentCustomer } from "@/lib/auth"; import { getApprovedReviews, getReviewSummary, getCustomerReview, hasCustomerPurchased } from "@/lib/reviews"; @@ -87,6 +87,7 @@ export default async function ProductDetailPage( // haengt bei einem kleinen Versandhandel oft direkt daran). const baseShippingCents = resolveShippingCostCents(shippingTiers, 1); + const base = getSiteUrl(); const jsonLd = { "@context": "https://schema.org", "@type": "Product", @@ -101,14 +102,14 @@ export default async function ProductDetailPage( // vortaeuschen wuerden (Google Rich-Results/Merchant-Anforderung: absolute // URL zu einem echten Produktfoto, keine Platzhaltergrafik). ...(product.images.length > 0 - ? { image: product.images.map((src) => `https://${siteConfig.domain}${src}`) } + ? { image: product.images.map((src) => `${base}${src}`) } : {}), offers: { "@type": "Offer", priceCurrency: product.currency, price: (product.priceCents / 100).toFixed(2), availability: inStock ? "https://schema.org/InStock" : "https://schema.org/OutOfStock", - url: `https://wabenhain.com/honig/${product.slug}`, + url: `${base}/honig/${product.slug}`, }, ...(reviewSummary.count > 0 ? { @@ -124,18 +125,18 @@ export default async function ProductDetailPage( "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "Shop", item: `https://${siteConfig.domain}/shop` }, + { "@type": "ListItem", position: 1, name: "Shop", item: `${base}/shop` }, { "@type": "ListItem", position: 2, name: t.common.categories[product.category], - item: `https://${siteConfig.domain}/shop?kategorie=${product.category}`, + item: `${base}/shop?kategorie=${product.category}`, }, { "@type": "ListItem", position: 3, name: product.name, - item: `https://${siteConfig.domain}/honig/${product.slug}`, + item: `${base}/honig/${product.slug}`, }, ], }; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d76e14e..ac736f8 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,7 +4,7 @@ import "./globals.css"; import { Header, CartDrawer } from "@/components/site-chrome"; import { Footer } from "@/components/site-footer"; import { CookieConsentBanner } from "@/components/cookie-consent"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getCompanyInfo } from "@/lib/company-info"; import { getSocialLinks } from "@/lib/social-links"; import { getLocale } from "@/i18n/locale"; @@ -26,7 +26,7 @@ export async function generateMetadata(): Promise { const company = await getCompanyInfo(); const title = `${company.name} | Regionaler Honig aus Altenmünster`; return { - metadataBase: new URL(`https://${siteConfig.domain}`), + metadataBase: new URL(getSiteUrl()), title: { default: title, template: `%s | ${company.name}` }, description: company.description, openGraph: { @@ -44,6 +44,7 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { const locale = await getLocale(); const dict = getDictionary(locale); const [company, socialLinks] = await Promise.all([getCompanyInfo(), getSocialLinks()]); + const base = getSiteUrl(); // LocalBusiness-Schema fuer die gesamte Seite (einmal im Root-Layout statt // auf jeder Seite einzeln) — hilft insbesondere bei lokaler Google-Suche @@ -54,10 +55,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { const localBusinessJsonLd = { "@context": "https://schema.org", "@type": "Store", - "@id": `https://${siteConfig.domain}/#business`, + "@id": `${base}/#business`, name: company.name, description: company.description, - url: `https://${siteConfig.domain}`, + url: base, address: { "@type": "PostalAddress", streetAddress: company.address.street, @@ -73,10 +74,10 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { "@context": "https://schema.org", "@type": "WebSite", name: company.name, - url: `https://${siteConfig.domain}`, + url: base, potentialAction: { "@type": "SearchAction", - target: `https://${siteConfig.domain}/shop?q={search_term_string}`, + target: `${base}/shop?q={search_term_string}`, "query-input": "required name=search_term_string", }, }; diff --git a/src/app/robots.ts b/src/app/robots.ts index 869faca..bf969bb 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -1,5 +1,13 @@ import type { MetadataRoute } from "next"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; + +// Muss zur Laufzeit statt zur Build-Zeit gerendert werden (gleicher Grund wie +// bei sitemap.ts): sonst backt Next.js die Datei beim Build als statische +// Antwort ein — samt dem Wert, den NEXT_PUBLIC_SITE_URL GERADE DA hatte. Im +// Docker-Build ist die Variable nicht gesetzt (siehe Dockerfile), die Sitemap- +// Zeile fror damit dauerhaft auf dem Rueckfallwert aus site-config.ts ein, +// egal unter welcher Domain die Seite spaeter tatsaechlich lief. +export const dynamic = "force-dynamic"; export default function robots(): MetadataRoute.Robots { return { @@ -10,6 +18,6 @@ export default function robots(): MetadataRoute.Robots { disallow: ["/admin", "/checkout", "/warenkorb", "/api", "/konto", "/rechnung"], }, ], - sitemap: `https://${siteConfig.domain}/sitemap.xml`, + sitemap: `${getSiteUrl()}/sitemap.xml`, }; } diff --git a/src/app/shop/page.tsx b/src/app/shop/page.tsx index da59eac..dbfb903 100644 --- a/src/app/shop/page.tsx +++ b/src/app/shop/page.tsx @@ -6,7 +6,7 @@ import { ProductCard } from "@/components/product-card"; import { RecentlyViewedSection } from "@/components/recently-viewed-section"; import { getActiveProducts, type ProductSort } from "@/lib/data"; import { getReviewSummariesByProductIds } from "@/lib/reviews"; -import { siteConfig } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; import type { ProductCategory } from "@/generated/prisma/client"; @@ -67,12 +67,13 @@ export default async function ShopPage(props: PageProps<"/shop">) { const products = await getActiveProducts({ query, category, sort, onlyAvailable }); const reviewSummaries = await getReviewSummariesByProductIds(products.map((p) => p.id)); + const base = getSiteUrl(); const breadcrumbJsonLd = { "@context": "https://schema.org", "@type": "BreadcrumbList", itemListElement: [ - { "@type": "ListItem", position: 1, name: "Start", item: `https://${siteConfig.domain}` }, - { "@type": "ListItem", position: 2, name: "Shop", item: `https://${siteConfig.domain}/shop` }, + { "@type": "ListItem", position: 1, name: "Start", item: base }, + { "@type": "ListItem", position: 2, name: "Shop", item: `${base}/shop` }, ], }; diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 7dd7f16..a300625 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,6 +1,7 @@ import type { MetadataRoute } from "next"; import { getActiveProducts, getHarvests } from "@/lib/data"; -import { wissenSlugs, siteConfig } from "@/lib/site-config"; +import { wissenSlugs } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; // priority/changeFrequency sind Hinweise an Crawler, keine Garantien — // die Startseite und der Shop aendern sich am haeufigsten und sind fuer @@ -27,7 +28,7 @@ const staticRoutes: { path: string; priority: number; changeFrequency: MetadataR export const dynamic = "force-dynamic"; export default async function sitemap(): Promise { - const base = `https://${siteConfig.domain}`; + const base = getSiteUrl(); const [products, harvests] = await Promise.all([getActiveProducts(), getHarvests()]); return [ diff --git a/src/app/wissen/[slug]/page.tsx b/src/app/wissen/[slug]/page.tsx index 3a88636..a4f2bb6 100644 --- a/src/app/wissen/[slug]/page.tsx +++ b/src/app/wissen/[slug]/page.tsx @@ -3,7 +3,8 @@ import { notFound } from "next/navigation"; import Link from "next/link"; import { Container } from "@/components/ui"; import { BackLink } from "@/components/back-link"; -import { wissenSlugs, siteConfig } from "@/lib/site-config"; +import { wissenSlugs } from "@/lib/site-config"; +import { getSiteUrl } from "@/lib/url"; import { getCompanyInfo } from "@/lib/company-info"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -42,7 +43,7 @@ export default async function WissenArticlePage( const otherArticles = wissenSlugs.filter((s) => s !== slug).slice(0, 2); const company = await getCompanyInfo(); - const base = `https://${siteConfig.domain}`; + const base = getSiteUrl(); // Kein datePublished/image: fuer diese Artikel existiert kein echtes // Veroeffentlichungsdatum und keine echte Fotografie — beides zu erfinden // waere irrefuehrende strukturierte Daten statt hilfreicher. diff --git a/src/lib/url.test.ts b/src/lib/url.test.ts new file mode 100644 index 0000000..f87479f --- /dev/null +++ b/src/lib/url.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { getSiteUrl, getBaseUrl } from "./url"; +import { siteConfig } from "./site-config"; + +const original = process.env.NEXT_PUBLIC_SITE_URL; + +afterEach(() => { + if (original === undefined) delete process.env.NEXT_PUBLIC_SITE_URL; + else process.env.NEXT_PUBLIC_SITE_URL = original; +}); + +describe("getSiteUrl", () => { + it("nutzt NEXT_PUBLIC_SITE_URL, wenn gesetzt", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de"; + + expect(getSiteUrl()).toBe("https://shop.beispiel.de"); + }); + + it("entfernt abschliessende Schraegstriche, damit keine doppelten entstehen", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de//"; + + // Zusammengesetzt ergaebe das sonst "https://shop.beispiel.de//shop" — + // fuer Google eine andere URL als die kanonische Variante. + expect(`${getSiteUrl()}/shop`).toBe("https://shop.beispiel.de/shop"); + }); + + it("faellt auf die konfigurierte Domain zurueck statt auf localhost", () => { + delete process.env.NEXT_PUBLIC_SITE_URL; + + // Eine vergessene Umgebungsvariable darf nicht dazu fuehren, dass + // "http://localhost:3000" in einer oeffentlichen Sitemap landet. + expect(getSiteUrl()).toBe(`https://${siteConfig.domain}`); + }); + + it("ignoriert einen Wert, der nur aus Leerzeichen besteht", () => { + process.env.NEXT_PUBLIC_SITE_URL = " "; + + expect(getSiteUrl()).toBe(`https://${siteConfig.domain}`); + }); + + it("liefert unter getBaseUrl denselben Wert — E-Mails und SEO duerfen nicht auseinanderlaufen", () => { + process.env.NEXT_PUBLIC_SITE_URL = "https://shop.beispiel.de"; + + expect(getBaseUrl()).toBe(getSiteUrl()); + }); +}); diff --git a/src/lib/url.ts b/src/lib/url.ts index eb82395..2cf9f5b 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -1,8 +1,36 @@ +import { siteConfig } from "@/lib/site-config"; + /** - * Basis-URL fuer absolute Links in E-Mails (Bestellbestaetigung, - * Newsletter-Opt-in, Passwort-Reset). Webhooks/Server Actions haben keinen - * verlaesslichen Request-Origin, deshalb NEXT_PUBLIC_SITE_URL statt Header. + * Oeffentliche Basis-URL der Seite, ohne abschliessenden Schraegstrich + * (z.B. "https://wabenhain.com"). + * + * EINZIGE Quelle fuer absolute Links — sowohl fuer E-Mails (Bestellbestaetigung, + * Newsletter-Opt-in, Passwort-Reset) als auch fuer alles, was Suchmaschinen + * sehen: metadataBase/Canonicals (app/layout.tsx), sitemap.xml, robots.txt und + * saemtliche JSON-LD-Bloecke. + * + * Vorher war das auf zwei Quellen verteilt: E-Mails nutzten + * NEXT_PUBLIC_SITE_URL, SEO-relevante Stellen dagegen ein fest verdrahtetes + * "https://" + siteConfig.domain. Laeuft die Seite unter einer anderen Domain + * (Staging, Vorschau-Host, spaeterer Domainwechsel), zeigten Sitemap, + * Canonical-Tags und strukturierte Daten damit geschlossen auf eine fremde + * Adresse: Google verwirft eine Sitemap mit fremden Hosts, und Canonicals auf + * eine andere Domain weisen die Indexierung aktiv von der echten Seite weg. + * + * siteConfig.domain bleibt als Rueckfallebene erhalten, damit eine vergessene + * Umgebungsvariable in Produktion nicht zu "http://localhost:3000" in einer + * oeffentlichen Sitemap fuehrt. + */ +export function getSiteUrl(): string { + const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (configured) return configured.replace(/\/+$/, ""); + return `https://${siteConfig.domain}`; +} + +/** + * Alias von getSiteUrl() unter dem in E-Mail-Code eingebuergerten Namen. + * Beide liefern denselben Wert — der Unterschied ist rein sprachlich. */ export function getBaseUrl(): string { - return process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"; + return getSiteUrl(); }