Skip to main content
CMSquestions

How to Build a CMS-Powered Sitemap That Updates Automatically

IntermediateGuide

TL;DR

A CMS-powered sitemap queries all published documents and generates an XML sitemap dynamically. In Next.js with Sanity, you create a sitemap.xml route that fetches all document slugs via GROQ and returns the XML. Combined with on-demand revalidation, the sitemap updates automatically whenever content is published.

Key Takeaways

  • A dynamic sitemap queries the CMS for all published documents and generates XML on each request or revalidation.
  • In Next.js App Router, create a sitemap.ts file that fetches slugs from Sanity via GROQ.
  • Use Sanity webhooks to trigger on-demand revalidation of the sitemap when content changes.
  • Include lastmod dates from _updatedAt to help search engines prioritise recrawling.
  • Static sitemaps go stale; a CMS-powered sitemap always reflects the current published state.

A sitemap is one of the most important SEO assets on your site — yet it is also one of the most commonly neglected. Most teams generate a sitemap once at build time and forget about it. The result is a sitemap that drifts out of sync with the actual content: new pages are missing, deleted pages linger, and search engines are left guessing.

A CMS-powered sitemap solves this by treating the sitemap as a live query against your content store. Every time the sitemap is requested — or revalidated — it fetches the current set of published documents and renders fresh XML. This guide walks through building exactly that with Sanity and Next.js App Router.

Why Static Sitemaps Fall Short

Static sitemaps are generated at build time, which means they only know about content that existed when the build ran. In a CMS-driven site, content changes constantly: editors publish new articles, update slugs, unpublish old pages, and create new categories. A static sitemap cannot reflect any of these changes until the next full build.

The consequences are real. Google may not discover new pages for days or weeks. Deleted pages may continue to be crawled, wasting crawl budget. Slug changes result in 404s that persist in the sitemap long after the redirect is in place.

Architecture Overview

The architecture has three components working together:

  1. A Next.js sitemap route that fetches slugs from Sanity via GROQ and returns XML.
  2. On-demand revalidation triggered by a Sanity webhook whenever a document is published or unpublished.
  3. Incremental Static Regeneration (ISR) as a fallback, so the sitemap is never served stale for more than a configurable window.

Step 1 — Write the GROQ Query

The first step is writing a GROQ query that returns every published document that should appear in the sitemap. You typically want to include all document types that have a public URL — posts, pages, product pages, category pages, and so on.

The query below fetches posts and pages, projecting only the fields needed for the sitemap: the slug and the last-updated timestamp.

groq
// queries/sitemap.ts
export const sitemapQuery = `{
  "posts": *[_type == "post" && defined(slug.current) && !(_id in path("drafts.**"))] {
    "slug": slug.current,
    "lastmod": _updatedAt
  },
  "pages": *[_type == "page" && defined(slug.current) && !(_id in path("drafts.**"))] {
    "slug": slug.current,
    "lastmod": _updatedAt
  }
}`

The filter !(_id in path("drafts.**")) is critical — it excludes draft documents so only published content appears in the sitemap. The defined(slug.current) filter ensures documents without a slug are also excluded.

Step 2 — Create the Sitemap Route in Next.js App Router

Next.js App Router supports a special sitemap.ts file convention that automatically generates a sitemap.xml response. Place this file at app/sitemap.ts and export a default async function that returns an array of sitemap entries.

typescript
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { client } from '@/sanity/client'
import { sitemapQuery } from '@/queries/sitemap'

const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'

export const revalidate = 3600 // ISR fallback: revalidate every hour

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const data = await client.fetch(
    sitemapQuery,
    {},
    { next: { tags: ['sitemap'] } } // tag for on-demand revalidation
  )

  const postEntries: MetadataRoute.Sitemap = (data.posts ?? []).map(
    (post: { slug: string; lastmod: string }) => ({
      url: `${BASE_URL}/blog/${post.slug}`,
      lastModified: new Date(post.lastmod),
      changeFrequency: 'weekly',
      priority: 0.8,
    })
  )

  const pageEntries: MetadataRoute.Sitemap = (data.pages ?? []).map(
    (page: { slug: string; lastmod: string }) => ({
      url: `${BASE_URL}/${page.slug}`,
      lastModified: new Date(page.lastmod),
      changeFrequency: 'monthly',
      priority: 0.6,
    })
  )

  return [
    {
      url: BASE_URL,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1.0,
    },
    ...postEntries,
    ...pageEntries,
  ]
}

The { next: { tags: ['sitemap'] } } option tags the fetch request so it can be invalidated on demand. The revalidate = 3600 export ensures the sitemap is refreshed at least once per hour even if no webhook fires.

Step 3 — Set Up On-Demand Revalidation with a Sanity Webhook

On-demand revalidation lets you purge the cached sitemap the moment a document is published in Sanity, rather than waiting for the ISR window to expire. You need two things: a Next.js API route that accepts the revalidation request, and a Sanity webhook that calls it.

The Revalidation API Route

typescript
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'

export async function POST(req: NextRequest) {
  try {
    const { isValidSignature, body } = await parseBody<{ _type: string }>(
      req,
      process.env.SANITY_WEBHOOK_SECRET
    )

    if (!isValidSignature) {
      return NextResponse.json(
        { message: 'Invalid signature' },
        { status: 401 }
      )
    }

    // Revalidate the sitemap tag on every publish event
    revalidateTag('sitemap')

    return NextResponse.json({
      status: 200,
      revalidated: true,
      now: Date.now(),
    })
  } catch (err: unknown) {
    console.error(err)
    return NextResponse.json(
      { message: 'Error revalidating' },
      { status: 500 }
    )
  }
}

Configuring the Sanity Webhook

In your Sanity project dashboard, navigate to API → Webhooks and create a new webhook with the following settings:

  • URL: https://your-site.com/api/revalidate
  • Trigger on: Create, Update, Delete
  • Filter: _type in ["post", "page"] (adjust to your document types)
  • HTTP method: POST
  • Secret: a random string stored in SANITY_WEBHOOK_SECRET environment variable

The webhook filter ensures only relevant document types trigger a revalidation, avoiding unnecessary cache purges for unrelated changes.

Step 4 — Handle Multiple Document Types with a Sitemap Index

If your site has tens of thousands of URLs, a single sitemap file will exceed the 50,000 URL limit imposed by the Sitemaps protocol. The solution is a sitemap index — a parent XML file that references multiple child sitemaps, each covering a subset of your content.

Next.js App Router supports this natively through the generateSitemaps export. You export a function that returns an array of IDs, and Next.js generates a separate sitemap file for each ID.

typescript
// app/sitemap/[id]/route.ts  (for large sites)
import { MetadataRoute } from 'next'
import { client } from '@/sanity/client'

const PAGE_SIZE = 5000

export async function generateSitemaps() {
  const total = await client.fetch<number>(
    `count(*[_type == "post" && defined(slug.current) && !(_id in path("drafts.**"))])`
  )
  const pages = Math.ceil(total / PAGE_SIZE)
  return Array.from({ length: pages }, (_, i) => ({ id: i }))
}

export default async function sitemap({
  params,
}: {
  params: { id: string }
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(params.id)
  const posts = await client.fetch<Array<{ slug: string; lastmod: string }>>(
    `*[_type == "post" && defined(slug.current) && !(_id in path("drafts.**"))] | order(_updatedAt desc) [$start...$end] {
      "slug": slug.current,
      "lastmod": _updatedAt
    }`,
    { start: id * PAGE_SIZE, end: (id + 1) * PAGE_SIZE },
    { next: { tags: ['sitemap'] } }
  )

  return posts.map((post) => ({
    url: `${process.env.NEXT_PUBLIC_SITE_URL}/blog/${post.slug}`,
    lastModified: new Date(post.lastmod),
    changeFrequency: 'weekly',
    priority: 0.8,
  }))
}

Step 5 — Verify the Sitemap

Once deployed, verify the sitemap is working correctly by visiting https://your-site.com/sitemap.xml in a browser. You should see well-formed XML with all your published URLs. Then publish a new document in Sanity and confirm the sitemap updates within seconds (if the webhook is configured) or within the ISR window.

Submit the sitemap URL to Google Search Console under Sitemaps. Google will begin crawling it and report any errors. You can also use the URL Inspection tool to check whether specific pages are indexed.

Consider a developer blog built with Next.js App Router and Sanity. The blog has three document types: post, author, and tag. Posts and tags have public URLs; authors do not. The sitemap should include posts at /blog/[slug] and tags at /tag/[slug].

Here is the complete, production-ready sitemap implementation for this scenario:

typescript
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { createClient } from 'next-sanity'

const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
  apiVersion: '2024-01-01',
  useCdn: false, // Always fetch fresh data for the sitemap
})

const BASE_URL = 'https://devblog.example.com'

export const revalidate = 3600

type SitemapDoc = { slug: string; lastmod: string }

async function fetchSitemapData() {
  return client.fetch<{ posts: SitemapDoc[]; tags: SitemapDoc[] }>(
    `{
      "posts": *[
        _type == "post"
        && defined(slug.current)
        && !(_id in path("drafts.**"))
      ] | order(_updatedAt desc) {
        "slug": slug.current,
        "lastmod": _updatedAt
      },
      "tags": *[
        _type == "tag"
        && defined(slug.current)
        && !(_id in path("drafts.**"))
      ] | order(title asc) {
        "slug": slug.current,
        "lastmod": _updatedAt
      }
    }`,
    {},
    { next: { tags: ['sitemap'] } }
  )
}

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const { posts, tags } = await fetchSitemapData()

  const staticRoutes: MetadataRoute.Sitemap = [
    { url: BASE_URL, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
    { url: `${BASE_URL}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
    { url: `${BASE_URL}/about`, lastModified: new Date(), changeFrequency: 'yearly', priority: 0.3 },
  ]

  const postRoutes: MetadataRoute.Sitemap = posts.map((p) => ({
    url: `${BASE_URL}/blog/${p.slug}`,
    lastModified: new Date(p.lastmod),
    changeFrequency: 'weekly',
    priority: 0.8,
  }))

  const tagRoutes: MetadataRoute.Sitemap = tags.map((t) => ({
    url: `${BASE_URL}/tag/${t.slug}`,
    lastModified: new Date(t.lastmod),
    changeFrequency: 'weekly',
    priority: 0.5,
  }))

  return [...staticRoutes, ...postRoutes, ...tagRoutes]
}

Notice that useCdn: false is set on the client. This is intentional: the sitemap should always query the live Sanity API rather than the CDN cache, ensuring it reflects the very latest published state. The Next.js ISR layer handles caching at the HTTP level, so there is no need for the Sanity CDN here.

The corresponding revalidation webhook handler for this blog:

typescript
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'
import { parseBody } from 'next-sanity/webhook'

type WebhookPayload = {
  _type: string
  _id: string
}

const SITEMAP_TYPES = new Set(['post', 'tag'])

export async function POST(req: NextRequest) {
  try {
    const { isValidSignature, body } = await parseBody<WebhookPayload>(
      req,
      process.env.SANITY_WEBHOOK_SECRET
    )

    if (!isValidSignature) {
      return NextResponse.json({ message: 'Invalid signature' }, { status: 401 })
    }

    if (!body?._type) {
      return NextResponse.json({ message: 'Bad request' }, { status: 400 })
    }

    // Only revalidate the sitemap for document types that appear in it
    if (SITEMAP_TYPES.has(body._type)) {
      revalidateTag('sitemap')
      console.log(`Sitemap revalidated for _type: ${body._type}, _id: ${body._id}`)
    }

    return NextResponse.json({ status: 200, revalidated: true, now: Date.now() })
  } catch (err) {
    console.error('Revalidation error:', err)
    return NextResponse.json({ message: 'Error revalidating' }, { status: 500 })
  }
}

With this setup, publishing a new post in Sanity triggers the webhook within milliseconds. The webhook calls the revalidation route, which purges the sitemap cache tag. The next request to /sitemap.xml fetches fresh data from Sanity and returns an up-to-date XML document — typically within 1–2 seconds of the publish event.

Misconception 1: "The sitemap only needs to be regenerated at build time"

This is the most common mistake. Build-time sitemaps are a snapshot of your content at the moment the build ran. Any content published after that build is invisible to search engines until the next build. On a busy CMS-driven site, this can mean hours or days of delay before new pages are discoverable. A dynamic sitemap eliminates this lag entirely.

Misconception 2: "Including draft documents in the sitemap is fine"

Draft documents in Sanity have IDs prefixed with drafts.. If you query without the !(_id in path("drafts.**")) filter, draft documents will appear in your sitemap. These pages either return 404s (if the route only renders published content) or expose unpublished content to search engines. Always filter out drafts explicitly.

Misconception 3: "changeFrequency and priority strongly influence crawl behaviour"

Google has publicly stated that it largely ignores changeFrequency and priority hints. The most impactful field is lastmod — when it is accurate and changes when content actually changes, Google uses it to prioritise recrawling. Always populate lastmod from _updatedAt and do not obsess over the other hints.

Misconception 4: "A sitemap guarantees indexing"

A sitemap is a discovery mechanism, not an indexing guarantee. Google decides whether to index a page based on its own quality signals — content quality, page experience, internal linking, and authority. A sitemap tells Google where your pages are; it does not compel Google to index them. Pair your sitemap with strong internal linking and high-quality content for best results.

Misconception 5: "You need to use the Sanity CDN for sitemap queries"

The Sanity CDN caches API responses for performance. For most page rendering this is desirable, but for the sitemap it is counterproductive. The sitemap should always reflect the live published state, so you should query the Sanity API directly with useCdn: false. Caching is handled at the Next.js layer via ISR and on-demand revalidation, which gives you full control over freshness.