In headless architectures, sitemap generation is no longer entirely dependent on the CMS. Although the backend continues to manage the content, the final routes are built on the frontend (Next.js, Nuxt, Gatsby, etc.). Therefore, generating a sitemap that correctly reflects the final URLs requires a different approach than a traditional site. If you want to have a clear understanding before moving forward, here we explain what an XML sitemap is and its relevance to SEO.
Generating a sitemap from the frontend (Next.js + headless CMS)
When pages exist as entries within a headless CMS (Contentful, Sanity, Strapi, Drupal in JSON:API mode, etc.), the most common approach is:
- Query the CMS via API
- Build the pages on the frontend
- Generate a sitemap dynamically with the final information (slugs, structure, update date)
Example: Sitemap in Next.js
First, we define a special page sitemap.xml.tsx:
// pages/sitemap.xml.tsx
export default class Sitemap extends Component {
static async getInitialProps({ res }: GetServerSidePropsContext): Promise<void> {
const pages = await getPages()
res.writeHead(200, { 'Content-Type': 'text/xml' })
res.write(createSitemap(pages))
res.end()
}
}
This endpoint:
- Gets the pages from the CMS (getPages)
- Generates the XML (createSitemap)
- Serves it directly as sitemap.xml
Define the CMS data type
export type ContentfulPage = {
title: string
slug: string
header: ContentfulOrHeader
blocks?: ContentfulBlock[]
footer: ContentfulOrFooter
updatedAt?: string
}
This type represents how a page arrives from Contentful, but the concept applies equally to any CMS: title, slug, components, and metadata such as updatedAt.
Función para generar el sitemap XML
const createSitemap = (pages: ContentfulPage[]) => {
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${generateLinks(pages)}
</urlset>`
}
All it does is wrap the items inside the <urlset>.
Generar los items individuales del sitemap
const generateLinks = (pages: ContentfulPage[]) => {
const pageItems = pages.map((page) => {
const slugPath = page.slug === '/' ? '' : `/${page.slug}`
const url = `${process.env.ORIGIN_URL}${slugPath}`
return `
<url>
<loc>${url}</loc>
<changefreq>daily</changefreq>
<lastmod>${page.updatedAt}</lastmod>
<priority>0.8</priority>
</url>
`
})
return pageItems.join('')
}
This is where the action happens: building real URLs from CMS slugs and metadata.
Getting pages from the CMS
import { client } from 'services/contentful'
export const getPages = async (): Promise<ContentfulPage[]> => {
const collection = await client.getEntries({ 'content_type': 'page' })
const pages = collection?.items?.length ? collection.items : null
if (pages) return pages.map((page) => ({
title: page.fields.title,
slug: page.fields.slug,
header: page.fields.header,
blocks: page.fields.blocks,
footer: page.fields.footer,
updatedAt: page.sys.updatedAt,
}))
return []
}
Contentful (and most headless CMSs) deliver data like this:
{
"fields": { "...": "..." },
"sys": { "updatedAt": "2023-01-04T00:50:34.525Z" }
}
This is enough to build correct URLs.
Using a Drupal-generated sitemap (and serving it in Node.js/Express)
On headless sites with Drupal as the backend, it is sometimes more efficient to let Drupal generate the sitemap using the XML Sitemap module and simply expose that file from the frontend.
This approach avoids having to replicate logic on the frontend or develop custom generators.
Steps for the Drupal → Node.js approach
1. Install and configure XML Sitemap in Drupal
Drupal automatically generates:
- /sitemap.xml
- Configurable content items
- Automatic regeneration when changes occur
2. In Node.js/Express, stream the sitemap
After testing different NPM modules, request is usually the quickest way to implement the stream:
app.get('/sitemap.xml', function (req, res) {
var sitemap = request(sitemapxml);
req.pipe(sitemap);
sitemap.pipe(res);
});
/sitemap.xml from the frontend directly serves the version generated by Drupal.
This approach works especially well because it avoids duplicating route logic between the backend and frontend: Drupal manages the content, and the sitemap is always kept in sync with any updates. In addition, Google only needs to query a single endpoint (https://frontend.com/sitemap.xml), which simplifies crawling. It is a very efficient solution for lightweight frontends, sites with a large volume of pages, or projects where it does not make sense to maintain two systems generating URLs.
Which method to choose?
Choosing the right method depends on how routes are managed on your site:
- Frontend defines the final routes: generate the sitemap directly from the frontend.
- Drupal controls the structure and URLs: serve the sitemap generated by Drupal.
- Highly dynamic sites with static generation (Next.js, Gatsby): the frontend can handle the sitemap.
- Lower budget or avoiding custom development: using Drupal's XML Sitemap is usually more convenient.
Both methods are valid; the final choice depends on the routing model and where the URLs are actually built.
Focus on implementation
On a headless site, generating a sitemap involves choosing correctly where the actual routes live.
If the frontend builds the URLs, it should generate the sitemap.
If Drupal controls the structure, it can generate it, and the frontend only exposes it.
The important thing is that the sitemap reflects the final URLs that Google can crawl.
Without that, any headless architecture loses indexing capability.