To build a dynamic sitemap in Payload CMS 3, create an app/sitemap.ts file in your Next.js app, query your published documents with Payload's Local API, and return them as a sitemap. Next.js serves it at /sitemap.xml, and a Payload afterChange hook revalidates it whenever content is published, updated or deleted. No sitemap package or custom Express endpoint is needed. This guide walks through the full setup, including noindex exclusions, large sites, multilingual alternates and robots.txt.
Updated for Payload 3 and the Next.js App Router. If you're still on Payload 2, see the custom endpoint section near the end.
What Is a Dynamic Sitemap?
A sitemap is an XML file that lists the URLs on your site you want search engines to crawl, with the date each one last changed. A dynamic sitemap is generated from your database instead of being written by hand, so new posts, updated pages and deleted content are reflected automatically. For a CMS-driven site with content changing every week, this is the only practical approach.
A good sitemap helps search engines discover new and updated pages faster. It doesn't improve rankings directly, but pages that aren't discovered can't rank at all.
Prerequisites
- Payload CMS 3.x running inside a Next.js App Router project
- Collections with a
slugfield (this guide usespagesandposts) - Drafts enabled on those collections, so
_statusis available (optional but recommended) - A
NEXT_PUBLIC_SERVER_URLenvironment variable containing your production URL
Step 1: Add a noIndex Field to Your Collections
Give editors a way to keep specific pages out of the sitemap, such as thank-you pages, landing pages for ads or legal duplicates. Add this field to every collection that appears in the sitemap:
{
name: 'noIndex',
type: 'checkbox',
defaultValue: false,
admin: {
position: 'sidebar',
description: 'Exclude this page from the sitemap and search results',
},
}
Use the same field in your page metadata to output robots: { index: false }, so excluded pages are also marked noindex, not just missing from the sitemap.
Step 2: Create the Sitemap Route
Create src/app/(frontend)/sitemap.ts. Next.js turns this file into /sitemap.xml automatically and handles the XML formatting and escaping for you.
// src/app/(frontend)/sitemap.ts
import type { MetadataRoute } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'
const SITE_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'https://example.com'
// Fallback: rebuild at most once an hour even if no hook fires
export const revalidate = 3600
type SitemapDoc = { slug?: string | null; updatedAt: string }
async function getPublished(collection: 'pages' | 'posts'): Promise<SitemapDoc[]> {
const payload = await getPayload({ config })
const docs: SitemapDoc[] = []
let page = 1
let hasNextPage = true
while (hasNextPage) {
const res = await payload.find({
collection,
where: {
and: [
{ _status: { equals: 'published' } },
{ noIndex: { not_equals: true } },
],
},
select: { slug: true, updatedAt: true },
depth: 0,
limit: 500,
page,
overrideAccess: false, // only include publicly readable documents
})
docs.push(...(res.docs as SitemapDoc[]))
hasNextPage = res.hasNextPage
page++
}
return docs.filter((doc) => Boolean(doc.slug))
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [pages, posts] = await Promise.all([getPublished('pages'), getPublished('posts')])
return [
...pages.map((doc) => ({
url: doc.slug === 'home' ? SITE_URL : `${SITE_URL}/${doc.slug}`,
lastModified: new Date(doc.updatedAt),
})),
...posts.map((doc) => ({
url: `${SITE_URL}/blog/${doc.slug}`,
lastModified: new Date(doc.updatedAt),
})),
]
}
Three details make this production-ready:
selectanddepth: 0fetch only the slug and date, so even thousands of documents load quickly. See our guide on scaling Payload CMS in production for why this matters.- Pagination loops through every page of results instead of silently stopping at a fixed limit.
overrideAccess: falseapplies your public read access rules, so anything a visitor can't see never leaks into the sitemap.
Next.js may try to generate the sitemap at build time. If your build environment can't reach the database, add export const dynamic = 'force-dynamic' to the file and rely on the CDN or the revalidate setting for caching.
Step 3: Revalidate the Sitemap When Content Changes
The sitemap is cached, so you need to tell Next.js when to rebuild it. Add these hooks to each collection in the sitemap:
// src/hooks/revalidateSitemap.ts
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
import { revalidatePath } from 'next/cache'
export const revalidateSitemap: CollectionAfterChangeHook = ({ doc, previousDoc, req }) => {
if (req.context?.disableRevalidate) return doc
const statusChanged = doc._status !== previousDoc?._status
const slugChanged = doc.slug !== previousDoc?.slug
const noIndexChanged = doc.noIndex !== previousDoc?.noIndex
if (doc._status === 'published' || statusChanged || slugChanged || noIndexChanged) {
revalidatePath('/sitemap.xml')
}
return doc
}
export const revalidateSitemapOnDelete: CollectionAfterDeleteHook = ({ doc, req }) => {
if (!req.context?.disableRevalidate) revalidatePath('/sitemap.xml')
return doc
}
// In your Posts and Pages collection configs
hooks: {
afterChange: [revalidateSitemap],
afterDelete: [revalidateSitemapOnDelete],
},
revalidatePath only works inside the Next.js server. When you run seed or import scripts outside Next.js, pass context: { disableRevalidate: true } to your Local API calls so the hook skips it.
Step 4: Point robots.txt at Your Sitemap
// src/app/(frontend)/robots.ts
import type { MetadataRoute } from 'next'
const SITE_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'https://example.com'
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/admin', '/api'] }],
sitemap: `${SITE_URL}/sitemap.xml`,
}
}
Step 5: Submit and Test
- Open
/sitemap.xmlin your browser and check that only published, indexable URLs appear, with correct absolute URLs. - Publish a test post, reload the sitemap, and confirm it appears. Unpublish it and confirm it disappears.
- In Google Search Console, go to Sitemaps and submit
sitemap.xml. Do the same in Bing Webmaster Tools. - Check the Sitemaps report a few days later for errors and for how many discovered URLs were indexed.
Large Sites: Split Into Multiple Sitemaps
A single sitemap can contain at most 50,000 URLs and 50 MB uncompressed. For large collections, use Next.js's generateSitemaps to split one collection into several files. Create the file inside the route segment for that collection:
// src/app/(frontend)/blog/sitemap.ts -> /blog/sitemap/0.xml, /blog/sitemap/1.xml, ...
import type { MetadataRoute } from 'next'
import { getPayload } from 'payload'
import config from '@payload-config'
const SITE_URL = process.env.NEXT_PUBLIC_SERVER_URL || 'https://example.com'
const PER_SITEMAP = 5000
const where = { _status: { equals: 'published' } } as const
export async function generateSitemaps() {
const payload = await getPayload({ config })
const { totalDocs } = await payload.count({ collection: 'posts', where })
const count = Math.max(1, Math.ceil(totalDocs / PER_SITEMAP))
return Array.from({ length: count }, (_, id) => ({ id }))
}
export default async function sitemap(props: {
id: Promise<number | string> | number | string
}): Promise<MetadataRoute.Sitemap> {
const id = Number(await props.id)
const payload = await getPayload({ config })
const { docs } = await payload.find({
collection: 'posts',
where,
select: { slug: true, updatedAt: true },
depth: 0,
limit: PER_SITEMAP,
page: id + 1,
sort: 'createdAt',
})
return docs.map((doc) => ({
url: `${SITE_URL}/blog/${doc.slug}`,
lastModified: new Date(doc.updatedAt),
}))
}
Sorting by createdAt keeps each URL in the same file over time. List every generated sitemap in robots.ts (the sitemap property accepts an array) or submit each one in Search Console.
Multilingual Sites: hreflang Alternates
If you use Payload's localization, add language alternates to each entry so search engines connect the translations of a page:
{
url: `${SITE_URL}/en/blog/${slug}`,
lastModified: new Date(updatedAt),
alternates: {
languages: {
en: `${SITE_URL}/en/blog/${slug}`,
ur: `${SITE_URL}/ur/blog/${slug}`,
},
},
}
Image Sitemaps
On recent Next.js versions, sitemap entries accept an images array. Select the featured image with depth: 1 and add its absolute URL so your images can be discovered for image search:
images: doc.heroImage?.url ? [`${SITE_URL}${doc.heroImage.url}`] : undefined,
Multi-Tenant Sites: One Sitemap per Domain
If one Payload instance serves several websites, each domain needs its own sitemap containing only that tenant's URLs. Resolve the tenant from the request host and filter every query by it. We cover this setup in our PayloadCMS multi-tenant development guide and the official multi-tenant plugin guide.
Sitemap Advice That No Longer Applies
Many older sitemap tutorials, including earlier versions of this one, recommend things that don't help:
- priority and changefreq: Google has stated it ignores both values. Spend your effort on accurate URLs and dates instead.
- Setting lastmod to the current time: Google uses
lastmodonly when it is consistently accurate. Use the document's realupdatedAt, nevernew Date(). - Manual gzip with a Content-Encoding header: your server or CDN compresses responses automatically. Setting the header yourself can produce broken responses.
- Pinging search engines after every update: Google retired its sitemap ping endpoint. Submit the sitemap once in Search Console, and Google re-fetches it on its own.
Not Using Next.js? A Custom Payload Endpoint
If your frontend is a separate app, or you're running Payload outside Next.js, you can generate the XML from a Payload custom endpoint. In Payload 3, endpoint handlers return a standard Response:
// payload.config.ts
endpoints: [
{
path: '/sitemap.xml',
method: 'get',
handler: async (req) => {
const site = process.env.NEXT_PUBLIC_SERVER_URL
const { docs } = await req.payload.find({
collection: 'posts',
where: { _status: { equals: 'published' } },
select: { slug: true, updatedAt: true },
depth: 0,
limit: 5000,
})
const urls = docs
.map((doc) =>
`<url><loc>${site}/blog/${encodeURIComponent(doc.slug)}</loc>` +
`<lastmod>${new Date(doc.updatedAt).toISOString()}</lastmod></url>`,
)
.join('')
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' } })
},
},
],
Payload serves custom endpoints under /api, so this lives at /api/sitemap.xml. Proxy or rewrite /sitemap.xml to it from your web server or frontend.
How to Create a Sitemap for Any Dynamic Website
The same pattern works for any database-driven site, whatever the framework: query only public, indexable records; build absolute URLs; use each record's real last-updated date; split into multiple files past 50,000 URLs; cache the result and invalidate it when content changes; reference it in robots.txt; and submit it once in Search Console.
Get Help With Your Payload CMS SEO
A sitemap is one part of technical SEO. Pair it with the official SEO plugin for metadata, as covered in our Payload CMS SEO guide. If you'd like an expert to set up sitemaps, metadata, redirects and structured data on your Payload site, talk to our Payload CMS development team.