diff --git a/app/api/revalidate-sitemap/route.ts b/app/api/revalidate-sitemap/route.ts index 0b970c1..2757e5a 100644 --- a/app/api/revalidate-sitemap/route.ts +++ b/app/api/revalidate-sitemap/route.ts @@ -1,4 +1,4 @@ -import { revalidatePath } from "next/cache" +import { revalidatePath, revalidateTag } from "next/cache" import { NextRequest, NextResponse } from "next/server" /** @@ -38,6 +38,7 @@ export async function POST(request: NextRequest): Promise { } try { + revalidateTag("sitemap", "default") revalidatePath("/sitemap.xml") return NextResponse.json({ revalidated: true, diff --git a/app/sitemap.ts b/app/sitemap.ts index 881e387..39bd36b 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,19 +1,36 @@ import type { MetadataRoute } from "next" +import { unstable_cache } from "next/cache" import { buildStaticEntries } from "@/lib/sitemap/build-static-entries" import { fetchDynamicEntries } from "@/lib/sitemap/fetch-dynamic-entries" +const REVALIDATE_SECONDS = Number(process.env.SITEMAP_REVALIDATE_SECONDS) || 3600 + /** - * ISR revalidation interval for the sitemap. + * Caches the full sitemap generation with time-based revalidation. * - * Instead of `force-dynamic`, we use ISR so the sitemap is cached and - * regenerated in the background. This keeps response times fast for - * crawlers while still reflecting recent changes on the site. + * Instead of `force-dynamic` (which hits the DB on every crawler request), + * we use `unstable_cache` so the sitemap is regenerated at most once per + * revalidation window. This keeps response times fast for crawlers while + * still reflecting recent changes on the site. + * + * Tagged with `"sitemap"` so the on-demand revalidation webhook can + * clear this cache immediately after content changes. + * + * @see https://nextjs.org/docs/app/api-reference/functions/unstable_cache */ -export const revalidate = Number(process.env.SITEMAP_REVALIDATE_SECONDS) || 3600 +const getCachedSitemap = unstable_cache( + async () => { + const staticEntries = buildStaticEntries() + const { gameEntries, deviceEntries } = await fetchDynamicEntries() + return [...staticEntries, ...gameEntries, ...deviceEntries] + }, + ["sitemap"], + { + revalidate: REVALIDATE_SECONDS, + tags: ["sitemap"], + }, +) export default async function sitemap(): Promise { - const staticEntries = buildStaticEntries() - const { gameEntries, deviceEntries } = await fetchDynamicEntries() - - return [...staticEntries, ...gameEntries, ...deviceEntries] + return getCachedSitemap() } diff --git a/lib/sitemap/__tests__/fetch-dynamic-entries.test.ts b/lib/sitemap/__tests__/fetch-dynamic-entries.test.ts index 3e30ecb..d50db21 100644 --- a/lib/sitemap/__tests__/fetch-dynamic-entries.test.ts +++ b/lib/sitemap/__tests__/fetch-dynamic-entries.test.ts @@ -64,9 +64,9 @@ describe("fetchDynamicEntries", () => { const result = await fetchDynamicEntries() expect(result.gameEntries).toHaveLength(2) - expect(result.gameEntries[0].url).toBe("https://deckyvault.xyz/games/game-1") + expect(result.gameEntries[0].url).toBe("https://deckyvault.xyz/game/game-1") expect(result.gameEntries[0].images).toEqual(["https://cdn.example.com/img1.jpg"]) - expect(result.gameEntries[1].url).toBe("https://deckyvault.xyz/games/game-2") + expect(result.gameEntries[1].url).toBe("https://deckyvault.xyz/game/game-2") expect(result.gameEntries[1].images).toBeUndefined() expect(result.deviceEntries).toHaveLength(1) })