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) {