Learn How to Generate sitemap.xml Using an Azure Function
AI links open with a title + excerpt (these tools can't fetch the page themselves) — use "Copy full article" to paste the complete text for a fuller summary.
Introduction
This article demonstrates how to generate sitemap.xml from an Azure Function, reading the live list of pages from the database instead of writing a file at build time.
A sitemap lists every URL you want indexed. Search engines use it to find pages they might otherwise miss and to see when something changed.
Why Not a Static File
A static sitemap is out of date the moment you publish. You would have to rebuild and redeploy the whole site just to add one URL to a list.
That matters more for us than for most sites, because our pages are prerendered — content changes when we deploy. But the blog list reads from the API at runtime, so a post can be live in the database before its page is built. A generated sitemap tells search engines about it immediately.
Features
- Always matches what is in the database.
- No build step and no file to commit.
- Costs one small Cosmos DB query per request, and search engines fetch it rarely.
With the following steps, you can build one.
- Query the pages.
- Escape the XML.
- Return the right content type.
- Rewrite the URL.
Query the Pages
const container = await getContainer(blogContainerName);
const { resources } = await container.items
.query('SELECT c.slug, c.pubDate FROM c ORDER BY c.pubDate DESC')
.fetchAll();
Two fields only. A sitemap needs a URL and a modified date, so selecting anything else is RU spent on bytes you throw away. In Cosmos DB the cost of a query grows with the data it reads, and this is a public endpoint that any crawler can hit.
Static routes are a constant, since they do not come from the database.
const STATIC_PATHS = ['/', '/blog', '/items', '/contact'];
And here is where ours was wrong. Look at that query again. It selects from the container with no WHERE clause at all.
Our blog container holds two kinds of document — posts, and comments tagged docType: 'comment'. The post endpoints filter on that tag. This query does not. So it returned comment documents too, which have no slug and no pubDate, and the very next line does this:
${post.pubDate.slice(0, 10)}
undefined.slice(0, 10) throws. The catch turns it into a 500, and the entire sitemap stops working.
getSitemap error: TypeError: Cannot read properties of undefined (reading 'slice')
It worked perfectly until the day comments shipped. Nothing about the comments feature touched this file. There is no test for it, and a broken sitemap produces no visible symptom on the site — you find out from your search console, weeks later, if you look.
The query needs the same rules the post endpoints already use:
SELECT c.slug, c.pubDate FROM c
WHERE (NOT IS_DEFINED(c.docType) OR c.docType = 'post')
AND (NOT IS_DEFINED(c.status) OR c.status = 'publish')
ORDER BY c.pubDate DESC
The docType half stops the crash. The status half is the bug underneath it — without it, draft posts are advertised to search engines even though the page returns 404 to anonymous visitors.
If you take one thing from this article, make it that. Your sitemap must obey the same visibility rules as your pages — and if it reads from a container that holds more than one kind of document, it must filter for the kind it wants, or it will break the first time somebody adds a new kind.
Escape the XML
function escapeXml(value: string): string {
return value.replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '<';
case '>': return '>';
case '&': return '&';
case "'": return ''';
case '"': return '"';
default: return c;
}
});
}
This is not optional, even though slugs look harmless.
A single & in a URL — a query string, or a slug generated from a title containing an ampersand — produces XML that will not parse. The search engine rejects the whole document, not just that one entry, and reports a vague fetch error days later in your search console.
All five characters matter. & is the one you will actually hit; the rest cost nothing to handle and remove the question.
Return the Right Content Type
return {
status: 200,
headers: { 'Content-Type': 'application/xml' },
body: xml,
};
Use body with an explicit Content-Type, not jsonBody. jsonBody would serialise your XML string into a JSON string — quoted and escaped — and serve it as application/json. The response looks almost right in a browser and is useless to a crawler.
The document itself is assembled by hand.
const xml =
'<?xml version="1.0" encoding="UTF-8"?>\n' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n' +
[...staticUrls, ...blogUrls].join('\n') +
'\n</urlset>';
No XML library. For a document this simple, string concatenation with a proper escape function is clearer than a builder, and there is less to go wrong.
Each blog entry carries a lastmod.
` <url><loc>${escapeXml(`${SITE_URL}/blog/${post.slug}`)}</loc><lastmod>${post.pubDate.slice(0, 10)}</lastmod></url>`
.slice(0, 10) turns an ISO timestamp into YYYY-MM-DD, which is what the sitemap spec wants. Absolute URLs are required — a relative path is not valid in a sitemap.
Rewrite the URL
The function lives at /api/sitemap, but crawlers expect /sitemap.xml. Static Web Apps can rewrite it, in staticwebapp.config.json:
{ "route": "/sitemap.xml", "rewrite": "/api/sitemap" }
A rewrite, not a redirect. The visitor's URL stays /sitemap.xml and the content is served from the function. A redirect would work too, but it is an extra round trip and some tools handle it poorly.
Put this route before the catch-all /* route. Rules are evaluated in order, and the catch-all would otherwise hand /sitemap.xml to the SPA fallback and return your index.html.
Check It
curl -s https://your-site.com/sitemap.xml | head -20
Check three things: it is XML and not JSON, the URLs are absolute, and there are no stray & characters. Then submit it in Google Search Console and add it to robots.txt.
Sitemap: https://www.example.com/sitemap.xml
Conclusion
In this article we learned how to generate a sitemap from Cosmos DB in an Azure Function, escape the XML properly, return it with the correct content type using body rather than jsonBody, and rewrite /sitemap.xml onto the API route.
The code is about 50 lines. The part to get right is not the XML — it is making sure the list of URLs in your sitemap matches the list of pages a visitor can actually see.
Reference
Comments
Be the first to comment.