From 06fee1739d47271ae5175d912b48ae9035ed915e Mon Sep 17 00:00:00 2001 From: Marco Date: Sat, 29 Aug 2026 21:28:42 +0000 Subject: [PATCH] Add auto-updating sitemap.xml and robots.txt Sitemap combines hardcoded static routes with Payload's Pages and published Posts collections (hourly ISR revalidation), so new CMS pages/blog posts appear without a code change. Excludes noindex transactional routes (checkout, cart, bestellbestaetigung, newsletter-confirmed, konto/*) to avoid Search Console warnings. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013Eg6h91yngXmSnM51wxXM8 --- app/lib/payload.ts | 43 ++++++++++++++++++++++++++++++++ app/robots.ts | 16 ++++++++++++ app/sitemap.ts | 62 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 app/robots.ts create mode 100644 app/sitemap.ts diff --git a/app/lib/payload.ts b/app/lib/payload.ts index 52fa7a0..72ec9c3 100644 --- a/app/lib/payload.ts +++ b/app/lib/payload.ts @@ -1518,3 +1518,46 @@ export async function getPageBySlug(slug: string, options?: { draft?: boolean }) if (!doc) return null; return mapPayloadPage(doc); } + +export type SitemapEntry = { slug: string; updatedAt: string }; + +// Backs app/sitemap.ts. Pages has no status/draft field (see Pages.ts) — +// every doc that exists is live — so unlike getBlogPosts there's no +// `where[status]` filter needed here. +export async function getAllPageSlugs(): Promise { + const params = new URLSearchParams({ + "where[tenant.slug][equals]": TENANT_SLUG, + depth: "0", + limit: "100", + }); + + const res = await fetch(`${PAYLOAD_URL}/api/pages?${params}`, { next: { revalidate: 3600 } }); + if (!res.ok) { + console.error(`getAllPageSlugs: Payload returned ${res.status} ${res.statusText}`); + return []; + } + + const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json(); + return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt })); +} + +// Same shape as getAllPageSlugs but for Posts, which — unlike Pages — does +// have a draft/scheduled state, so this filters to `published` the same +// way getBlogPosts does (the sitemap must never list a post that 404s). +export async function getAllPostSlugs(): Promise { + const params = new URLSearchParams({ + "where[tenant.slug][equals]": TENANT_SLUG, + "where[status][equals]": "published", + depth: "0", + limit: "100", + }); + + const res = await fetch(`${PAYLOAD_URL}/api/posts?${params}`, { next: { revalidate: 3600 } }); + if (!res.ok) { + console.error(`getAllPostSlugs: Payload returned ${res.status} ${res.statusText}`); + return []; + } + + const data: { docs?: { slug: string; updatedAt: string }[] } = await res.json(); + return (data.docs ?? []).map((doc) => ({ slug: doc.slug, updatedAt: doc.updatedAt })); +} diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..b03176c --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from "next"; + +const SITE_URL = "https://einfach-produktiv.mk360.de"; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: "*", + allow: "/", + disallow: ["/checkout", "/cart", "/konto", "/bestellbestaetigung", "/newsletter-confirmed", "/email-preview", "/company-settings-preview", "/api"], + }, + ], + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/app/sitemap.ts b/app/sitemap.ts new file mode 100644 index 0000000..d7cde5e --- /dev/null +++ b/app/sitemap.ts @@ -0,0 +1,62 @@ +import type { MetadataRoute } from "next"; +import { getAllPageSlugs, getAllPostSlugs } from "./lib/payload"; + +const SITE_URL = "https://einfach-produktiv.mk360.de"; + +// Every indexable static route in this repo. Deliberately excludes: +// - transactional/session pages already marked `robots: noindex` in their +// own page.tsx (checkout, checkout/verarbeitung, cart, bestellbestaetigung, +// newsletter-confirmed, company-settings-preview, /konto/*) — Google +// explicitly advises against listing noindex URLs in a sitemap. +// - /sticker/[code] and /r/[code]: redirect-only routes with no content of +// their own (see resolveAndTrackRedirect), not pages to index. +// - /email-preview/[type]: internal template-preview tool, not customer-facing. +// - /blog/[slug] and /[slug] (Payload Pages): appended dynamically below. +const STATIC_ROUTES = [ + "", + "/blog", + "/shop", + "/newsletter", + "/todo-cards", + "/der-eine", + "/tasse-die-pause", + "/die-sieben", + "/einfach-anfangen", + "/7-tage-klarheits-check", + "/sticker", + "/agb", + "/datenschutz", + "/impressum", + "/versand", + "/widerruf", +]; + +// New routes only ever need adding in one of two places from now on: a +// plain static page.tsx goes in STATIC_ROUTES above; anything authored +// through Payload (a Pages doc or a blog Post) shows up here automatically +// the next time this regenerates — no code change needed for those. +export default async function sitemap(): Promise { + const [pages, posts] = await Promise.all([getAllPageSlugs(), getAllPostSlugs()]); + + const staticEntries: MetadataRoute.Sitemap = STATIC_ROUTES.map((path) => ({ + url: `${SITE_URL}${path}`, + changeFrequency: path === "" ? "daily" : "weekly", + priority: path === "" ? 1 : 0.7, + })); + + const pageEntries: MetadataRoute.Sitemap = pages.map(({ slug, updatedAt }) => ({ + url: `${SITE_URL}/${slug}`, + lastModified: updatedAt, + changeFrequency: "monthly", + priority: 0.6, + })); + + const postEntries: MetadataRoute.Sitemap = posts.map(({ slug, updatedAt }) => ({ + url: `${SITE_URL}/blog/${slug}`, + lastModified: updatedAt, + changeFrequency: "monthly", + priority: 0.5, + })); + + return [...staticEntries, ...pageEntries, ...postEntries]; +}