How to Build an SEO-Friendly Dynamic Sitemap in Payload CMS 3 (Next.js)

Published on by

Execudea

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 slug field (this guide uses pages and posts)
  • Drafts enabled on those collections, so _status is available (optional but recommended)
  • A NEXT_PUBLIC_SERVER_URL environment 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:

  • select and depth: 0 fetch 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: false applies 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

  1. Open /sitemap.xml in your browser and check that only published, indexable URLs appear, with correct absolute URLs.
  2. Publish a test post, reload the sitemap, and confirm it appears. Unpublish it and confirm it disappears.
  3. In Google Search Console, go to Sitemaps and submit sitemap.xml. Do the same in Bing Webmaster Tools.
  4. 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 lastmod only when it is consistently accurate. Use the document's real updatedAt, never new 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.

Frequently Asked Questions

How does a Payload CMS sitemap update automatically?

With Payload 3 and Next.js, the sitemap is cached and rebuilt automatically whenever content is published, updated, unpublished or deleted, using an afterChange or afterDelete hook that calls revalidatePath('/sitemap.xml'). A time-based revalidate value acts as a fallback.

What's the maximum number of URLs in a Payload CMS sitemap?

Each sitemap file can contain up to 50,000 URLs and 50 MB uncompressed. For larger sites, use Next.js generateSitemaps to split a collection across multiple sitemap files and list them all in robots.txt or Search Console.

Does Payload CMS generate a sitemap automatically?

Payload doesn't generate sitemaps on its own. With Payload 3 you add a sitemap.ts file to your Next.js app that queries Payload's Local API. It's only a few dozen lines and gives you full control over which URLs are included.

How do I exclude pages from my Payload CMS sitemap?

Add a noIndex checkbox field to your collections and filter it out in the sitemap query, along with any document that isn't published. Using overrideAccess: false also ensures only publicly readable documents are included.

Should I set priority and changefreq in my sitemap?

No. Google has said it ignores priority and changefreq. It uses lastmod only when the dates are consistently accurate, so always use each document's real updatedAt value.