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.
// 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.
// 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.
/product/sitemap/0.xml
/product/sitemap/1.xml
/product/sitemap/2.xmlEvery 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.
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.
// 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.
// 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.
| Version | Change |
|---|---|
| 16.0 | The id is passed as a promise that resolves to a string |
| 15.0 | Generated 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
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.
Frequently Asked Questions
When do I actually need to split a sitemap?
What URLs do the split sitemaps get?
Is the id a string or a number?
How do crawlers discover the numbered files?
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.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.