Sitemap Chunking
Introduction
When dealing with large datasets, sitemap sources can be chunked into multiple files to:
- Stay within search engine limits (50MB file size, 50,000 URLs)
- Improve generation performance
- Better manage memory usage
Simple Configuration
Enable chunking on any named sitemap with sources:
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: true, // Uses default size of 1000
}
}
}
})This generates:
/sitemap_index.xml # Master index
/posts-0.xml # First chunk (1-1000)
/posts-1.xml # Second chunk (1001-2000)
...Controlling URL Order
By default, sortEntries: true sorts all resolved URLs by loc before splitting them into chunks. The order returned by your source is not preserved.
Set sortEntries: false when chunk boundaries should follow your source order:
export default defineNuxtConfig({
sitemap: {
sortEntries: false,
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: true,
}
}
}
})Keep the source order deterministic. Inserting or removing an entry before an existing chunk boundary moves URLs between sitemap files, so update the sitemap index lastmod for every file that changed.
Chunk Size Options
Configure chunk sizes using different approaches:
export default defineNuxtConfig({
sitemap: {
// Global default
defaultSitemapsChunkSize: 5000,
sitemaps: {
// Using boolean (applies default)
posts: {
sources: ['/api/posts'],
chunks: true,
},
// Using number as size
products: {
sources: ['/api/products'],
chunks: 10000,
},
// Using explicit chunkSize (highest priority)
articles: {
sources: ['/api/articles'],
chunks: true,
chunkSize: 2000,
}
}
}
})Skipping the index source fetch (chunkCount)
By default the sitemap index calls your source to count URLs, so it knows how many <sitemap> entries to emit. At very large scale this cold-start fetch is the bottleneck. If you already know the number of chunks, declare it upfront and the index will skip the fetch entirely:
export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: ['/api/posts'],
chunks: 5000,
chunkCount: 100, // 100 chunk entries, no source fetch in the index
},
},
},
})Per-chunk renders still fetch on demand and slice. If your data set grows past the declared count, tail entries are unreachable; if it shrinks, trailing chunks render empty. Update the value when your data set changes (or remove it to fall back to fetching).
Practical Examples
E-commerce Site
export default defineNuxtConfig({
sitemap: {
defaultSitemapsChunkSize: 10000,
sitemaps: {
products: {
sources: ['/api/products/all'],
chunks: 2000,
},
categories: {
sources: ['/api/categories'],
chunks: true, // Uses default 10k
}
}
}
})Large Content Site
export default defineNuxtConfig({
sitemap: {
sitemaps: {
'blog-posts': {
sources: ['/api/blog/posts'],
chunks: 5000,
},
'authors': {
sources: ['/api/authors'],
chunks: false, // Explicitly disable
}
}
}
})Source Implementation
Chunks of the same sitemap share one resolved URL cache entry in production, so your source endpoint is called once per cache window, not once per chunk. Write the endpoint the same way you would without chunking.
Use defineSitemapEventHandler(). It types the return value as SitemapUrlInput[] and adds no caching of its own:
import { defineSitemapEventHandler } from '#sitemap/server'
export default defineSitemapEventHandler(async () => {
const products = await db.products.findAll({
select: ['id', 'slug', 'updatedAt']
})
return products.map(product => ({
loc: `/products/${product.slug}`,
lastmod: product.updatedAt
}))
})Switch to defineCachedEventHandler() when the query itself is expensive, or when several named sitemaps read this endpoint. Annotate the return value, because this handler has no sitemap-aware types:
import type { SitemapUrlInput } from '#sitemap/types'
export default defineCachedEventHandler(async (): Promise<SitemapUrlInput[]> => {
const products = []
const cursor = db.products.cursor({
select: ['slug', 'updatedAt']
})
for await (const product of cursor) {
products.push({
loc: `/products/${product.slug}`,
lastmod: product.updatedAt
})
}
return products
}, {
maxAge: 60 * 60, // 1 hour cache
name: 'sitemap-products'
})See Which handler should I use? for the full rule.
Debugging
Check chunk configuration and performance:
export default defineNuxtConfig({
sitemap: {
debug: true,
sitemaps: {
products: {
sources: ['/api/products'],
chunks: 5000
}
}
}
})Visit /__sitemap__/debug.json to see chunk details and generation metrics.
When built-in chunking isn't enough
Built-in chunking splits a sorted URL list by position. Delete one URL and every chunk after it shifts. The files change, but their lastmod dates have no way to say so.
If you need honest lastmod dates, chunk at the source instead. The same applies when your data outgrows a fixed set of sitemaps while the server runs. In both cases, register the chunks yourself with the sitemap:sitemaps-resolved hook.
You decide the boundaries in your database. Each chunk gets a source endpoint that returns only its own URLs. Then you register one sitemap per chunk.
import { defineNitroPlugin } from 'nitropack/runtime'
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('sitemap:sitemaps-resolved', async ({ sitemaps }) => {
// chunkCount comes from your database: ceil(totalGames / CHUNK_SIZE)
for (let chunk = 0; chunk < chunkCount(); chunk++) {
sitemaps[`games-${chunk}`] = {
sitemapName: `games-${chunk}`,
sources: [`/api/__sitemap__/games?chunk=${chunk}`],
}
}
})
})export default defineSitemapEventHandler((event) => {
const { chunk } = getQuery(event)
// SELECT ... WHERE id >= chunk * CHUNK_SIZE AND id < (chunk + 1) * CHUNK_SIZE
return gamesForChunk(Number(chunk)).map(game => ({
loc: `/game/${game.id}/${game.slug}`,
lastmod: game.updatedAt,
}))
})Each request fetches one chunk, so a chunk only pays for its own URLs. To set an honest lastmod per chunk in the index, pull it from your data with the sitemap:index-resolved hook.
Chunk by stable ranges
Pick boundaries that don't move. If chunk 2 holds games 10001 to 15000, it should always hold those games. Delete game 10002 and chunk 2 keeps its boundaries, so its lastmod only changes when its own content changes. Positional chunks shift on every delete, which makes their lastmod meaningless.
Boundaries don't have to be numeric. Whatever is stable in your data works: months of publication, category slugs, ID hashes.
Growing and shrinking
The hook starts from a fresh copy of the sitemap config on every run. Don't try to be incremental. Register everything you want each time.
Growth needs no special handling: the next run registers the new chunk. Shrinking is the same in reverse. Stop registering a chunk and it leaves the index; its route returns a 404. delete is only needed for static sitemaps you declared in nuxt.config and no longer want.
Caching
In development the hook runs on every request, so changes show up immediately.
In production with cacheMaxAgeSeconds set, the resolved sitemap list is cached for the same window as the sitemaps themselves. Register or remove freely. The change lands on the next refresh, on the same schedule your sitemap content already follows.
One timing detail after a removal: the sitemap list and the sitemap index refresh on their own schedules. For up to one cache window, the index may still list a sitemap whose route already returns a 404. Crawlers retry. If the window feels too long for your traffic, lower cacheMaxAgeSeconds.
For a complete working setup, see the dynamic-sitemaps example.