`generateSitemaps` for Large Sites

Split a large sitemap into numbered files with generateSitemaps in the Next.js App Router: chunking by id, the generated URLs, the missing sitemap index, and how crawlers find the parts.

8 min read

generateSitemaps splits one oversized sitemap into several numbered files. You export it next to a sitemap function, return one entry per file, and Next.js calls the sitemap function once per entry with that id. It exists because the sitemap format caps a single file at 50,000 URLs and 50MB uncompressed.

The behavior below was verified with Next.js 16.3 in an App Router project, requesting the generated files from a production build that uses the default caching model, with the Cache Components flag off. Below the size cap, one plain sitemap file is the better choice.

Deciding how many files to build

The first export answers one question: how many sitemaps are there. Compute it from a count rather than hardcoding a number, so the answer stays right as the catalog grows.

typescripttypescript
// app/product/sitemap.ts
import type { MetadataRoute } from 'next'
import { PER_SITEMAP, countProducts, getProductPage } from '@/app/lib/catalog'
 
const base = 'https://acme.com'
 
export async function generateSitemaps() {
  const total = await countProducts()
  const pages = Math.ceil(total / PER_SITEMAP)
  return Array.from({ length: pages }, (_, id) => ({ id }))
}

A catalog of 120,000 products now produces three entries, with ids 0 through 2. This function runs at build time on the server, so a cheap count query is worth more here than loading every record.

Each returned object must carry an id, and that value is the only thing connecting an entry to the file it produces. The ids do not have to be sequential integers, but sequential ones make the generated paths predictable.

Keep the page size in one exported constant and import it wherever a chunk boundary is computed. Two different numbers across these files is the most common way a generateSitemaps setup starts skipping records, and nothing in the build output would flag it. The single sitemap article covers the shape of the entries themselves.

Building one chunk at a time

The default export is the same sitemap function as before, except that it now receives the id. In Next.js 16 the id arrives as a promise, so it has to be awaited before use. It lives in the same file, under the export above.

typescripttypescript
// app/product/sitemap.ts
type Props = { id: Promise<string> }
 
export default async function sitemap({ id }: Props): Promise<MetadataRoute.Sitemap> {
  const page = Number(await id)
  const products = await getProductPage(page, PER_SITEMAP)
  return products.map((p) => ({
    url: `${base}/product/${p.slug}`,
    lastModified: p.updatedAt,
  }))
}

Each generated file now holds one page of products, and the last one holds the remainder. Fetch the slice in the query, as this does, rather than loading everything and slicing in memory, which defeats the purpose of splitting.

Chunk on a stable ordering such as an id or a creation date. Ordering by something that changes, such as a popularity score, reshuffles URLs between files on every build and makes the output noisy for crawlers.

The URLs you get

Each entry becomes its own route, named by its id under the segment path. Requesting one returns normal sitemap XML.

texttext
/product/sitemap/0.xml
/product/sitemap/1.xml
/product/sitemap/2.xml

Every one of these is prerendered at build time, which the build output confirms by listing each numbered file. That is the behavior you want, since the data is fixed for the lifetime of the deploy.

Requesting one returns the same XML a single sitemap would, with the slice of URLs belonging to that id. The last file holds whatever remains, so a catalog that is not an exact multiple of the page size still comes out complete.

Adding content between deploys does not change these files. If the catalog grows continuously, pair the split with revalidation, which the article on cache revalidation explains.

There is no combined file

Next.js does not generate an index at /product/sitemap.xml, and requesting that path returns a 404. The numbered files exist on their own and need to be announced somewhere.

Making the parts discoverable

The simplest option is the robots file, whose sitemap field accepts an array. This is enough for most sites and needs no extra route.

typescripttypescript
// app/robots.ts
import type { MetadataRoute } from 'next'
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/' },
    sitemap: ['https://acme.com/product/sitemap/0.xml'],
  }
}

The generated robots.txt then carries one Sitemap line per entry. The weakness is obvious once the count changes, since a hardcoded list drifts from reality on the next build.

A sitemap index solves that by listing the files programmatically. Next.js has no convention for one, so it is an ordinary Route Handler that returns XML.

typescripttypescript
// app/sitemap-index.xml/route.ts
import { PER_SITEMAP, countProducts } from '@/app/lib/catalog'
 
export async function GET() {
  const pages = Math.ceil((await countProducts()) / PER_SITEMAP)
  const items = Array.from({ length: pages }, (_, id) =>
    `<sitemap><loc>https://acme.com/product/sitemap/${id}.xml</loc></sitemap>`
  ).join('')
  const xml = `<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${items}</sitemapindex>`
  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } })
}

Requesting /sitemap-index.xml returns a valid index served as XML, and it stays correct as the count changes because it is computed the same way the sitemaps are. Point the robots file at this one URL instead of at every part.

Version differences worth knowing

Two changes affect code copied from older tutorials, and both are silent rather than loud.

VersionChange
16.0The id is passed as a promise that resolves to a string
15.0Generated URLs became consistent between development and production

Older examples read the id directly and build a numeric range from it. That code now yields a promise where a value is expected, so the slice boundaries come out wrong instead of throwing.

The safest habit is to await the id on its own line and convert it once, as the earlier example does. A generateSitemaps file that produces the right number of sitemaps with the wrong contents is easy to ship, because every URL in it is still valid.

Common mistakes

These generateSitemaps failures build cleanly and still mislead crawlers, which is what makes them worth checking by hand.

  • Splitting a catalog that fits in one file, which adds routes and discovery work for nothing.
  • Using the id without awaiting it, which quietly produces the wrong slice.
  • Loading the entire table in each chunk and slicing in memory.
  • Expecting an index at the segment path, which does not exist.
  • Leaving the numbered files unannounced, so nothing crawls them.
  • Chunking on an unstable ordering, which reshuffles URLs between files on every build.
  • Changing the page size in one place only, so a generateSitemaps run builds the right number of files with overlapping or missing records.
Rune AI

Rune AI

Key Insights

  • Export generateSitemaps from a sitemap file to produce one sitemap per returned id.
  • The default export receives that id as a promise and returns the slice of URLs for it.
  • Files are served at paths such as /product/sitemap/0.xml, and each is prerendered at build.
  • No combined file exists at /product/sitemap.xml, so the numbered files need announcing.
  • Split only past the 50,000 URL limit, and chunk on a stable ordering to keep files consistent.
RunePowered by Rune AI

Frequently Asked Questions

When do I actually need to split a sitemap?

When one file would exceed 50,000 URLs or 50MB uncompressed. Below that, a single sitemap file is simpler and works fine.

What URLs do the split sitemaps get?

They are served under the segment path with a numbered file, such as /product/sitemap/0.xml. There is no combined file at /product/sitemap.xml.

Is the id a string or a number?

Your function receives a promise. Await it before using it, since Next.js 16 passes the id as a promise that resolves to the value returned by generateSitemaps.

How do crawlers discover the numbered files?

They do not discover them on their own. List them in the robots file, or serve a sitemap index that points at each one.

Conclusion

generateSitemaps splits one oversized sitemap into numbered files, each built from a slice of your data. Chunk by a stable ordering, await the id before using it, and remember that Next.js does not create an index for you, so the numbered files still need to be announced in robots or through your own index route.