Type something to search...
Generating Dynamic Sitemaps With Sanity

Generating Dynamic Sitemaps With Sanity

To generate a dynamic sitemap with Sanity, query every routable published document with GROQ, map each one to its URL with select(), use _updatedAt as the last-modified date, and leave out anything marked no-index. In the Next.js App Router, return those entries from an app/sitemap.ts file and Next.js serves them as /sitemap.xml. In other frameworks, render the same data as XML from a server route or at build time. Revalidate the sitemap when content is published, so new pages are discoverable within minutes instead of waiting for the next deploy.

A hand-maintained sitemap goes stale the moment an editor publishes a new page. Because Sanity holds every slug, type and update time in structured fields, a single query can describe your entire site. This guide covers the query, a Next.js implementation, accurate dates, excluding pages, multilingual alternates, image entries, splitting large sitemaps, caching and revalidation, implementations for Astro and other frameworks, and how to check the result.

What a Sitemap Needs From Sanity

Each sitemap entry is a URL plus optional metadata. From Sanity you need:

Sitemap fieldSource in SanityNotes
loc (URL)_type plus slug.currentMapped to your route structure
lastmod_updatedAtChanges whenever the published document changes
Exclusionseo.noIndex, missing slugLeave these out entirely
Language alternatesTranslation metadataOptional, for multilingual sites
ImagesMain image asset URLOptional image sitemap extension

Google documents that it ignores the priority and changefreq values, and uses lastmod when it's consistently accurate. So focus on correct URLs and honest dates rather than tuning priorities.

Write the Sitemap Query

Fetch every routable type in one request and compute the path in GROQ, so URL rules live in one place:

// src/sanity/queries.ts
import { defineQuery } from 'next-sanity'

export const SITEMAP_QUERY = defineQuery(`
  *[
    _type in ["page", "post", "category", "author"]
    && defined(slug.current)
    && seo.noIndex != true
  ]{
    "path": select(
      _type == "page" && slug.current == "home" => "/",
      _type == "page" => "/" + slug.current,
      _type == "post" => "/blog/" + slug.current,
      _type == "category" => "/blog/category/" + slug.current,
      _type == "author" => "/authors/" + slug.current
    ),
    _updatedAt
  } | order(path asc)
`)

What each part does:

  • _type in [...]: only types that have their own page. Settings, navigation and reusable blocks stay out.
  • defined(slug.current): documents without a slug have no URL.
  • seo.noIndex != true: respects the no-index switch from your SEO fields. This condition is also true when the field has never been set. The SEO metadata guide shows that field.
  • select(): maps each type to its route. Put the most specific conditions first, such as the home page.

Drafts and unpublished release versions are excluded automatically when you query with the published perspective, which is the default for API version 2025-02-19 and later. Filtering and functions are covered in filtering and sorting with GROQ.

Build sitemap.ts in Next.js

Create app/sitemap.ts and return an array of entries. Use a client with stega disabled and the CDN off, because a sitemap should reflect the latest published content and contain no Visual Editing characters:

// src/app/sitemap.ts
import type { MetadataRoute } from 'next'
import { client } from '@/sanity/lib/client'
import { SITEMAP_QUERY } from '@/sanity/queries'

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

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const entries = await client
    .withConfig({ useCdn: false, stega: false })
    .fetch(SITEMAP_QUERY, {}, { next: { tags: ['sitemap'], revalidate: 3600 } })

  return entries
    .filter((entry) => entry.path)
    .map((entry) => ({
      url: `${BASE_URL}${entry.path}`,
      lastModified: entry._updatedAt,
    }))
}

Next.js turns this into valid XML at /sitemap.xml:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://www.example.com/</loc>
    <lastmod>2026-10-30T09:12:44Z</lastmod>
  </url>
  <url>
    <loc>https://www.example.com/blog/getting-started</loc>
    <lastmod>2026-11-02T14:03:10Z</lastmod>
  </url>
</urlset>

The filter guards against documents whose type matched but whose path came back null, for example if you add a type to the list and forget its select() branch.

Add Routes That Aren't in Sanity

Some pages come from code, such as a blog index or a contact page. Merge them in:

const staticRoutes: MetadataRoute.Sitemap = [
  { url: `${BASE_URL}/blog` },
  { url: `${BASE_URL}/contact` },
]

return [...staticRoutes, ...sanityEntries]

For the blog index, you can set lastModified to the newest post's _updatedAt, so it changes whenever a post is published.

Get the Dates Right

_updatedAt on a published document changes whenever that document is published again, which is exactly what lastmod means: the content at that URL changed. There are two subtleties:

  • Don't use it as a publish date: _updatedAt changes on every edit, so it's wrong for "published on" labels. Use a dedicated publishedAt field there.
  • Referenced content: if a post page shows the author's name and the author changes, the post's _updatedAt doesn't. That's usually fine. If you want those pages to report a change, compute the latest date in GROQ:
*[_type == "post" && defined(slug.current)]{
  "path": "/blog/" + slug.current,
  "_updatedAt": select(
    author->_updatedAt > _updatedAt => author->_updatedAt,
    _updatedAt
  )
}

Keep this to references that genuinely change the page, or every page will look updated whenever a shared document changes.

Add Language Alternates

For a multilingual site, sitemap entries can list alternate language versions, which helps search engines serve the right one. Next.js supports this through alternates.languages. With the document internationalisation plugin, translations are linked by a translation.metadata document, so you can collect them in GROQ:

*[_type == "post" && defined(slug.current) && seo.noIndex != true]{
  language,
  "slug": slug.current,
  _updatedAt,
  "translations": *[_type == "translation.metadata" && references(^._id)][0]
    .translations[].value->{ language, "slug": slug.current }
}
return posts.map((post) => ({
  url: `${BASE_URL}/${post.language}/blog/${post.slug}`,
  lastModified: post._updatedAt,
  alternates: {
    languages: Object.fromEntries(
      (post.translations ?? [])
        .filter((t) => t?.language && t.slug)
        .map((t) => [t!.language, `${BASE_URL}/${t!.language}/blog/${t!.slug}`]),
    ),
  },
}))

Your exact query depends on how you model translations. The multilingual content post compares document-level and field-level approaches.

Include Images

Image sitemaps can help images appear in image search. Next.js supports an images array per entry. Build the URL with the image URL builder, or simply project the asset URL:

*[_type == "post" && defined(slug.current)]{
  "path": "/blog/" + slug.current,
  _updatedAt,
  "image": mainImage.asset->url
}
.map((entry) => ({
  url: `${BASE_URL}${entry.path}`,
  lastModified: entry._updatedAt,
  images: entry.image ? [entry.image] : undefined,
}))

This is optional. Add it when image search matters to your site, and only include images that are meaningful content rather than decorations.

Split Large Sitemaps

A single sitemap file can hold up to 50,000 URLs. Past that, split it. Next.js provides generateSitemaps, which returns a list of IDs and serves each part at /sitemap/[id].xml. In Next.js 16, the id is passed to your function as a Promise that resolves to a string:

// src/app/blog/sitemap.ts
import type { MetadataRoute } from 'next'
import { defineQuery } from 'next-sanity'
import { client } from '@/sanity/lib/client'

const BASE_URL = 'https://www.example.com'
const PER_SITEMAP = 50000

const COUNT_QUERY = defineQuery(`count(*[_type == "post" && defined(slug.current)])`)
const PAGE_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)]
  | order(_id asc)[$start...$end]{ "slug": slug.current, _updatedAt }`)

const sitemapClient = client.withConfig({ useCdn: false, stega: false })

export async function generateSitemaps() {
  const total = await sitemapClient.fetch(COUNT_QUERY)
  const parts = Math.max(1, Math.ceil(total / PER_SITEMAP))
  return Array.from({ length: parts }, (_, id) => ({ id }))
}

export default async function sitemap(props: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id)
  const start = id * PER_SITEMAP
  const posts = await sitemapClient.fetch(PAGE_QUERY, { start, end: start + PER_SITEMAP })

  return posts.map((post) => ({
    url: `${BASE_URL}/blog/${post.slug}`,
    lastModified: post._updatedAt,
  }))
}

Ordering by _id keeps the split stable between requests. For very large datasets, a 50,000-document slice is a heavy query, so consider smaller parts (for example 10,000 URLs each) or the cursor-based approach from Pagination With GROQ. List each part in a sitemap index or in robots.ts so crawlers can find them.

Cache and Revalidate the Sitemap

In Next.js, sitemap.ts is a special Route Handler that's cached by default. The example above combines two strategies:

  • A sitemap tag, so a webhook can refresh it the moment something is published.
  • A one-hour revalidate as a fallback in case a webhook is missed.

Add the sitemap tag to the revalidation handler you already use for pages:

// In src/app/api/revalidate/route.ts, after validating the signature
revalidateTag(body._type, 'max')
revalidateTag('sitemap', 'max')

Only document types that appear in the sitemap need to trigger it, and only create, delete, slug changes and no-index changes actually alter the output. A webhook filter like this keeps calls down:

_type in ["page", "post", "category", "author"]

The full handler is in Improving Site Performance With Sanity Caching, and webhook setup is in How to Use Sanity Webhooks.

Point Crawlers at the Sitemap

Reference the sitemap from robots.txt. In Next.js, app/robots.ts generates it:

// src/app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/' },
    sitemap: 'https://www.example.com/sitemap.xml',
  }
}

Then submit the sitemap URL once in Google Search Console and Bing Webmaster Tools.

Sitemaps in Astro and Other Frameworks

Outside Next.js, generate the XML yourself. The data and query are the same. Here's an Astro endpoint that builds the file at build time for a static site:

// src/pages/sitemap.xml.ts
import type { APIRoute } from 'astro'
import { sanityClient } from 'sanity:client'

const QUERY = `*[_type in ["page", "post"] && defined(slug.current) && seo.noIndex != true]{
  "path": select(_type == "post" => "/blog/" + slug.current, "/" + slug.current),
  _updatedAt
}`

const escapeXml = (value: string) =>
  value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')

export const GET: APIRoute = async ({ site }) => {
  const entries: { path: string; _updatedAt: string }[] = await sanityClient.fetch(QUERY)

  const urls = entries
    .map(
      (entry) => `  <url>
    <loc>${escapeXml(new URL(entry.path, site).href)}</loc>
    <lastmod>${entry._updatedAt}</lastmod>
  </url>`,
    )
    .join('\n')

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`

  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}

This uses the sanity:client module from the @sanity/astro integration and the site value from astro.config.mjs. On a static site, the sitemap updates whenever a webhook triggers a rebuild. The Sanity and Astro guide covers the integration. The same pattern works as a server route in SvelteKit, Nuxt or React Router: fetch, map to URLs, escape and return XML with a cache header.

Check Your Sitemap

Before you submit it, check a few things:

  1. Open /sitemap.xml in a browser and confirm the URLs and dates look right.
  2. Count entries against a GROQ count() of the same filter, so nothing is missing.
  3. Spot-check URLs: every one should return 200, not a redirect or 404.
  4. Check exclusions: a page marked no-index shouldn't appear.
  5. Watch Search Console: the sitemap report shows discovered URLs and any errors after submission.

FAQ: Dynamic Sitemaps With Sanity

No. Sanity stores the content, but your front end owns the URLs, so the sitemap is generated by your framework. One GROQ query that maps documents to paths is usually all you need.

Yes, for most sites. On a published document it changes whenever the published content changes, which matches what lastmod means. Don't use it as a visible publish date, though, because any edit changes it.

Query with the published perspective, which is the default for API version 2025-02-19 and later. Drafts and unpublished release versions are then excluded automatically, without extra filters.

That's up to your caching. In Next.js, tag the sitemap fetch and revalidate the tag from a Sanity webhook so it updates within moments of publishing, with a time-based fallback such as one hour. Static sites update on each rebuild.

Not for Google, which says it ignores both values. Focus on including every indexable URL, excluding the rest and keeping lastmod accurate. Other search engines may treat them differently, but correct URLs matter far more.

When you approach 50,000 URLs, the limit for a single sitemap file, or when files get very large. In Next.js, use generateSitemaps to split by ID, and list the parts so crawlers can find them.


Conclusion

A dynamic sitemap is one of the easiest wins in a Sanity project. A single GROQ query finds every routable, indexable, published document, select() maps it to its URL, and _updatedAt supplies an honest last-modified date. In Next.js, app/sitemap.ts turns that into XML; in other frameworks, a small endpoint does the same job.

Tie the sitemap into the revalidation you already have, with a tag refreshed by your Sanity webhook and a time-based fallback, and new content becomes discoverable as soon as it's published. Add language alternates or image entries where they help, split the file if you grow past 50,000 URLs, and let Search Console tell you if anything goes wrong.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper