Files
Wabenhain/src/app/layout.tsx
T
Nick SzaunigandClaude Opus 5 d3020cd4da fix: Sitemap, Canonicals und JSON-LD folgen der echten Domain
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) <[email protected]>
2026-09-15 10:00:59 +02:00

119 lines
4.1 KiB
TypeScript

import type { Metadata } from "next";
import { Cormorant_Garamond, Inter } from "next/font/google";
import "./globals.css";
import { Header, CartDrawer } from "@/components/site-chrome";
import { Footer } from "@/components/site-footer";
import { CookieConsentBanner } from "@/components/cookie-consent";
import { getSiteUrl } from "@/lib/url";
import { getCompanyInfo } from "@/lib/company-info";
import { getSocialLinks } from "@/lib/social-links";
import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary";
import { I18nProvider } from "@/i18n/i18n-provider";
const cormorant = Cormorant_Garamond({
variable: "--font-cormorant",
subsets: ["latin"],
weight: ["400", "500", "600"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
});
export async function generateMetadata(): Promise<Metadata> {
const company = await getCompanyInfo();
const title = `${company.name} | Regionaler Honig aus Altenmünster`;
return {
metadataBase: new URL(getSiteUrl()),
title: { default: title, template: `%s | ${company.name}` },
description: company.description,
openGraph: {
title,
description: company.description,
siteName: company.name,
locale: "de_DE",
type: "website",
},
twitter: { card: "summary_large_image", title, description: company.description },
};
}
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
// ("Honig Altenmünster") und der Kartenanzeige. telephone ist hier bewusst
// NICHT enthalten: solange niemand im Admin-Bereich (Inhalte) eine echte
// Telefonnummer eingetragen hat, bliebe sonst der site-config-Platzhalter
// als strukturierte Daten faelschlich indexiert.
const localBusinessJsonLd = {
"@context": "https://schema.org",
"@type": "Store",
"@id": `${base}/#business`,
name: company.name,
description: company.description,
url: base,
address: {
"@type": "PostalAddress",
streetAddress: company.address.street,
postalCode: company.address.zip,
addressLocality: company.address.city,
addressCountry: "DE",
},
};
// Ermoeglicht Googles Sitelinks-Suchfeld direkt in den Suchergebnissen — die
// Ziel-URL entspricht der tatsaechlich vorhandenen Shop-Suche (?q=...).
const websiteJsonLd = {
"@context": "https://schema.org",
"@type": "WebSite",
name: company.name,
url: base,
potentialAction: {
"@type": "SearchAction",
target: `${base}/shop?q={search_term_string}`,
"query-input": "required name=search_term_string",
},
};
return (
<html
lang={locale}
data-scroll-behavior="smooth"
className={`${cormorant.variable} ${inter.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-background text-ink">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(localBusinessJsonLd).replace(/</g, "\\u003c") }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd).replace(/</g, "\\u003c") }}
/>
<I18nProvider locale={locale}>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50 focus:bg-forest focus:text-cream focus:px-4 focus:py-2 focus:text-sm"
>
{dict.nav.skipToContent}
</a>
<Header companyName={company.name} />
<main id="main-content" className="flex-1">
{children}
</main>
<Footer companyInfo={company} socialLinks={socialLinks} />
<CartDrawer />
<CookieConsentBanner />
</I18nProvider>
</body>
</html>
);
}