English

Splitting a Next.js sitemap 404s the URL you submitted to Google

We had one sitemap with about 1,100 URLs across four content types. That is nowhere near the 50,000 cap, so splitting it was not about size — it was about reporting. Search Console reports coverage per sitemap, and one document can only ever answer "N of 1,074 indexed", which cannot tell you whether it is the quote pages or the answer pages that are not being taken.

Next has this built in. In your `sitemap.ts`:

export function generateSitemaps() {
  return [{ id: 'pages' }, { id: 'answers' }, { id: 'quotes' }]
}

export default async function sitemap({ id }) { … }

That produces /sitemap/pages.xml, /sitemap/answers.xml and /sitemap/quotes.xml. All three serve correctly, all three validate.

And /sitemap.xml stops existing

Next generates the parts. It does not generate the index over them. The moment you add generateSitemaps(), the route that used to answer at /sitemap.xml — the URL in your robots.txt, the URL you submitted to Search Console, the URL Google re-fetches on its own schedule — returns a 404.

Nothing warns you. The build succeeds. The route listing shows the parts, cheerfully. Every URL you think to test works, because the ones you think to test are the new ones. The only thing that breaks is the address nobody looks at because it has been working for weeks.

We caught it by checking the built route list rather than assuming, and then by requesting the old URL specifically. That second check is the one worth keeping: after any change to how a thing is served, request the old address, not the new one.

The index, by hand

A route handler at app/sitemap.xml/route.ts. The segment can contain a dot, so this resolves at exactly the path the parts vacated:

export const dynamic = 'force-static'

export function GET() {
  const body = [
    '<?xml version="1.0" encoding="UTF-8"?>',
    '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
    ...SETS.map((s) => `<sitemap><loc>${SITE_URL}/sitemap/${s}.xml</loc></sitemap>`),
    '</sitemapindex>',
  ].join('\n')

  return new Response(body, { headers: { 'Content-Type': 'application/xml' } })
}

Build it from the same constant generateSitemaps() uses. Two hand-maintained lists is how a fifth content type ships listed in one place and missing from the other.

One more thing, if you have a locale in your URLs

We serve English unprefixed and rewrite /blog to /en/blog in middleware, redirecting /en/… back so one page never answers at two addresses. Sitemap paths contain a dot, so exclude them from the matcher — otherwise /sitemap/quotes.xml gets rewritten into a page route that does not exist, and you are back to a 404 that the build is perfectly happy about.

Apuntes del taller.