From d6c40b03cfa9d5ef2559664e011ee830a7a4558e Mon Sep 17 00:00:00 2001 From: Flotapponnier <160007691+Flotapponnier@users.noreply.github.com> Date: Sun, 26 Jul 2026 06:31:55 +0200 Subject: [PATCH] sitemap: route.ts handler with explicit Cache-Control (#1483) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next Metadata routes ignore next.config.ts headers() — the previous sitemap.ts left Next in charge of the response headers and hard-coded Cache-Control: public, max-age=0, must-revalidate on force-dynamic sitemaps. Every crawler hit re-ran the full loader chain (x-vercel-cache: MISS observed on prod 2026-07-26 after PR #1473 landed the config-level attempt). Confirmed by curl: my next.config /sitemap.xml rule was silently dropped. Move the builder logic to src/lib/sitemap-builder.ts and add a Route Handler at src/app/sitemap.xml/route.ts that returns a raw Response with: Content-Type: application/xml Cache-Control: public, s-maxage=3600, stale-while-revalidate=86400 Same builder logic, same buildFullSitemap + buildStaticFallback, just serialized to XML in the route and shipped with a real edge cache header. Also drop the now-redundant /sitemap.xml rule from next.config headers(). Co-authored-by: Florent Tapponnier --- next.config.ts | 17 +----- src/app/sitemap.xml/route.ts | 61 +++++++++++++++++++ .../sitemap.ts => lib/sitemap-builder.ts} | 2 +- 3 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 src/app/sitemap.xml/route.ts rename src/{app/sitemap.ts => lib/sitemap-builder.ts} (99%) diff --git a/next.config.ts b/next.config.ts index ece0984d..a68ed197 100644 --- a/next.config.ts +++ b/next.config.ts @@ -121,23 +121,12 @@ const nextConfig: NextConfig = { }, ], }, - { - // Sitemap: force-dynamic in the route file (Data Cache 2MB cap - // blows past for the 500+ URL corpus), so nothing caches it by - // default and every crawler hit re-runs the full loader chain. - // Hold it edge-side for an hour with SWR so cold hits stay fast. - source: "/sitemap.xml", - headers: [ - { - key: "Cache-Control", - value: "public, s-maxage=3600, stale-while-revalidate=86400", - }, - ], - }, { // Citable JSON is polled by LLM crawlers (Perplexity, ChatGPT, // Claude Deep Research) — a bare `public` with no s-maxage sent - // every scrape to origin. Same freshness window as sitemap. + // every scrape to origin. Sitemap's Cache-Control is set inside + // its route.ts (`src/app/sitemap.xml/route.ts`) because metadata + // routes ignore next.config headers(). source: "/api/citable", headers: [ { diff --git a/src/app/sitemap.xml/route.ts b/src/app/sitemap.xml/route.ts new file mode 100644 index 00000000..792642d9 --- /dev/null +++ b/src/app/sitemap.xml/route.ts @@ -0,0 +1,61 @@ +import type { MetadataRoute } from "next"; +import { buildSitemap } from "@/lib/sitemap-builder"; + +// Route Handler (not Metadata Route). Reason: the previous +// `src/app/sitemap.ts` metadata route left Next in charge of the +// response headers and hard-coded `Cache-Control: public, max-age=0, +// must-revalidate` on force-dynamic sitemaps — every crawler hit +// re-ran the full loader chain (`x-vercel-cache: MISS` observed +// 2026-07-26). A user-side `next.config.ts headers()` entry for +// `/sitemap.xml` was silently ignored because metadata routes emit +// their own Cache-Control that overrides the config layer. +// +// Route Handlers return a raw `Response`, so we control the headers +// end-to-end. Same builder logic (buildSitemap in `src/lib/`), just +// serialized to XML here and shipped with a real edge cache header. +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +function escapeXml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function toIso(v: MetadataRoute.Sitemap[number]["lastModified"]): string | null { + if (!v) return null; + const d = v instanceof Date ? v : new Date(v); + return Number.isNaN(d.getTime()) ? null : d.toISOString(); +} + +function serialize(entries: MetadataRoute.Sitemap): string { + const urls = entries + .map((e) => { + const parts = [`${escapeXml(e.url)}`]; + const iso = toIso(e.lastModified); + if (iso) parts.push(`${iso}`); + if (e.changeFrequency) parts.push(`${e.changeFrequency}`); + if (e.priority !== undefined) parts.push(`${e.priority}`); + parts.push(``); + return parts.join(""); + }) + .join(""); + return `\n\n${urls}\n`; +} + +export async function GET() { + const entries = await buildSitemap(); + return new Response(serialize(entries), { + headers: { + "Content-Type": "application/xml; charset=utf-8", + // Edge holds the sitemap for an hour + SWR window so the loader + // chain only runs once per hour per region rather than on every + // crawler hit. Aligns with the /api/citable header set in + // next.config.ts. + "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", + }, + }); +} diff --git a/src/app/sitemap.ts b/src/lib/sitemap-builder.ts similarity index 99% rename from src/app/sitemap.ts rename to src/lib/sitemap-builder.ts index 04545af2..67a158e4 100644 --- a/src/app/sitemap.ts +++ b/src/lib/sitemap-builder.ts @@ -504,7 +504,7 @@ async function buildFullSitemap(): Promise { ]; } -export default async function sitemap(): Promise { +export async function buildSitemap(): Promise { try { return await buildFullSitemap(); } catch (err) {