The site I was working on has thousands of pages, so a single sitemap was never going to cut it. The plan was the classic one: a sitemap index at the root pointing to several per-section sub-sitemaps. Next.js has a generateSitemaps convention that looked made for exactly this, so I used it, deployed, and checked from the terminal like I always do:
curl https://site.example/sitemap.xmlThe response was HTTP 200. The content-type was application/xml. Every coarse indicator was green. But the moment I read the body, something was off. What I expected was a sitemapindex document listing sitemap entries pointing to the sub-sitemaps. What I got instead was an empty <urlset></urlset>. Not an index, not an error, just a urlset containing not a single URL.
The symptom is deceptive
What makes this one annoying is that none of the usual failure signals fired. The 200 kept my monitoring quiet. The correct content-type made me trust the route was running. If I had only done a curl -I and seen a 200, I would have called the sitemap done and moved on. Exactly the kind of bug that survives review because "it returns 200, right?"
My first instinct was a bad one: I assumed my generateSitemaps had bad data, maybe a query returning an empty array so no URLs made it in. I logged the entry count per sitemap, and the numbers were correct — thousands of URLs. The data was there. Yet the root /sitemap.xml still returned an empty urlset. The sections were populated, the index was not. That was the first hint that the problem was not in the data, but in the shape of document Next.js was willing to produce.
The root cause
Once I stopped blaming the data and started reading what Next.js actually serializes, it clicked. The MetadataRoute.Sitemap type in Next.js is an array of entries shaped like { url, lastModified, ... }. Whatever you return from a sitemap function, Next.js serializes it into a single <urlset> document. Always. There is no type variant, no option, no flag that makes it emit a <sitemapindex> schema.
And generateSitemaps does not change this the way I assumed. The convention helps you split one giant urlset into several smaller urlsets, for example /sitemap/0.xml, /sitemap/1.xml, and so on. But the parent /sitemap.xml is still just the default function returning a urlset. There is no point inside this convention where you can tell Next.js "this one is not a urlset, it is an index pointing to other urlsets."
So the data was not wrong, and the deploy had not failed. I was simply using a convention that, by design, cannot produce the document shape I needed. A sitemap index and a urlset are two different XML schemas, and the MetadataRoute.Sitemap convention only knows one of them.
The fix
The fix is to stop fighting the convention and write the XML myself with manual route handlers. The root emits sitemapindex, each section emits urlset, and I keep the XML builders in one shared file so there is no duplication.
The first step, and this matters, is to delete app/sitemap.ts first. As long as that file exists, it collides with my manual route handler at the same path and Next.js keeps serving the convention's urlset. That was the exact reason my fix initially seemed to have no effect.
// lib/sitemap-helpers.ts
export function sitemapIndexXml(sitemaps: { loc: string; lastmod?: string }[]) {
const items = sitemaps
.map((s) => `<sitemap><loc>${s.loc}</loc>${s.lastmod ? `<lastmod>${s.lastmod}</lastmod>` : ""}</sitemap>`)
.join("");
return `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${items}</sitemapindex>`;
}
export function urlSetXml(urls: { loc: string; lastmod?: string }[]) {
const items = urls
.map((u) => `<url><loc>${u.loc}</loc>${u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : ""}</url>`)
.join("");
return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${items}</urlset>`;
}The root route emits the index pointing at each section:
// app/sitemap.xml/route.ts
import { sitemapIndexXml } from "@/lib/sitemap-helpers";
export async function GET() {
const base = "https://site.example";
const sections = ["posts", "products", "pages"];
const xml = sitemapIndexXml(
sections.map((name) => ({ loc: `${base}/sitemap/${name}.xml` }))
);
return new Response(xml, { headers: { "Content-Type": "application/xml" } });
}Each section has a dynamic route that emits its own urlset:
// app/sitemap/[name].xml/route.ts
import { urlSetXml } from "@/lib/sitemap-helpers";
import { getUrlsForSection } from "@/lib/data";
export async function GET(_req: Request, { params }: { params: { name: string } }) {
const urls = await getUrlsForSection(params.name);
const xml = urlSetXml(urls);
return new Response(xml, { headers: { "Content-Type": "application/xml" } });
}Now /sitemap.xml returns a real sitemapindex, and each /sitemap/<section>.xml returns a populated urlset.
How to verify properly
The bigger lesson from this bug is not only about Next.js, but about how I verify. A 200 with application/xml is not enough. Both can be true while the document body is completely wrong. So now I grep the shape explicitly:
curl -s https://site.example/sitemap.xml | grep -o '<sitemapindex' && echo "index OK"
curl -s https://site.example/sitemap/posts.xml | grep -o '<urlset' && echo "urlset OK"If the grep for <sitemapindex> returns nothing at the root, the sitemap is the wrong shape no matter how perfect the status and headers look.
Checklist
- Delete
app/sitemap.tsfirst, or it collides and keeps serving the convention urlset. - Remember
MetadataRoute.Sitemapalways emits<urlset>, never<sitemapindex>. - Add
app/sitemap.xml/route.tsto emit the index with<sitemap><loc>references. - Add
app/sitemap/[name].xml/route.tsto emit a per-section urlset. - Keep the XML builders in
lib/sitemap-helpers.tsso the index and urlset share code. - Verify by curling and grepping for
<sitemapindex>, do not just trust a 200 and content-type.
