generateSitemaps

Split one route's sitemap into numbered files so each stays under the 50,000 URL limit.

Signature
export async function generateSitemaps(): Promise<Array<{ id: number | string }>>

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap>
  • Added in Next.js v13.3.2

Parameters

ParameterTypeDescription
$props.idPromise<string>Passed to the file's default sitemap export, not to generateSitemaps itself: a promise resolving to one of the ids returned above. Await it, then use it to select that chunk of URLs.

Return value

Array<{ id: number | string }>An array of objects each carrying an id. Next.js emits one sitemap file per entry and calls the file's default export once per id.

Changelog

  • PHP v16.0.0The id handed to the sitemap function is now a Promise resolving to a string and must be awaited; before this it arrived as a plain value on props.id.
  • PHP v15.0.0Generated sitemap URLs are consistent between development and production.
  • PHP v13.3.2Introduced. In that release development served the files at /.../sitemap.xml/[id] rather than /.../sitemap/[id].xml.

A single sitemap.ts is enough until the URL count grows. Search engines cap a sitemap at 50,000 URLs, and building one giant array also means holding every row in memory during the build. generateSitemaps solves both: return one entry per chunk, and the file's default export is invoked once per id to produce that chunk.

Where the files land

Each generated file is served at /.../sitemap/[id].xml — for app/product/sitemap.ts returning ids 0 to 3, that is /product/sitemap/0.xml through /product/sitemap/3.xml. Nothing generates an index file linking them together, so list the chunk URLs in robots.txt or submit them individually.

What trips people up

  • props.id is a Promise and must be awaited — forgetting the await is the usual source of an empty chunk. It also resolves to a string: multiplication coerces it, which is why the official example computes start straight from the awaited value, but + would concatenate, so wrap it in Number() before adding to it.
  • The id list should be derived from a real count, not hard-coded, or new rows silently fall off the end.
  • Keep each chunk's query bounded; the point is to avoid loading the whole table.

Example

Example · typescript
import type { MetadataRoute } from 'next'
import { BASE_URL } from '@/app/lib/constants'
import { countProducts, getProducts } from '@/app/lib/products'

const CHUNK_SIZE = 50_000 // Google's limit is 50,000 URLs per sitemap

export async function generateSitemaps() {
  const total = await countProducts()
  const chunks = Math.ceil(total / CHUNK_SIZE)

  return Array.from({ length: chunks }, (_, id) => ({ id }))
}

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id)

  const start = id * CHUNK_SIZE
  const end = start + CHUNK_SIZE

  const products = await getProducts(start, end)

  return products.map((product) => ({
    url: `${BASE_URL}/product/${product.id}`,
    lastModified: product.updatedAt,
  }))
}

When to use it

  • Emit one sitemap file per 50,000 products in a large catalogue.
  • Chunk a user-generated content archive so each build step stays within memory.
  • Split a multi-locale site into one sitemap per language.
  • Keep a news sitemap separate from the evergreen pages of the same route.

Frequently asked questions

What is the URL of a sitemap created by generateSitemaps?

It is /.../sitemap/[id].xml, taking the path from the file's own location — app/product/sitemap.ts with id 1 is served at /product/sitemap/1.xml. Versions before 15 used a different path in development (/product/sitemap.xml/1), which is why older tutorials disagree; from 15 onward development and production match.

How do I split a sitemap with more than 50,000 URLs in Next.js?

Export generateSitemaps from the sitemap file returning one { id } per chunk of at most 50,000 URLs, then have the default export slice its chunk out using the awaited id. Derive the number of chunks from a live count rather than hard-coding it, so new rows do not fall outside the generated range.

Does Next.js generate a sitemap index file?

No — generateSitemaps emits the numbered sitemap files but no index that references them. List each generated URL in the sitemap array of robots.ts, or submit them to search consoles directly.

More examples

The shape of one generated file

Two ids produce two files: /product/sitemap/0.xml holds products a and b, /product/sitemap/1.xml holds c and d. The comment shows the XML Next.js writes for the first file — the MetadataRoute.Sitemap fields map onto loc, lastmod, changefreq and priority. Note Number(await props.id): the id resolves to the string "1", and "1" * 2 would work by coercion while "1" + 2 would not.

Example · typescript
// app/product/sitemap.ts
import type { MetadataRoute } from 'next'

export async function generateSitemaps() {
  return [{ id: 0 }, { id: 1 }]
}

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id)

  const rows = [
    { id: 'a', updatedAt: '2024-01-01' },
    { id: 'b', updatedAt: '2024-01-02' },
    { id: 'c', updatedAt: '2024-01-03' },
    { id: 'd', updatedAt: '2024-01-04' },
  ].slice(id * 2, id * 2 + 2)

  return rows.map((row) => ({
    url: `https://acme.com/product/${row.id}`,
    lastModified: row.updatedAt,
    changeFrequency: 'weekly',
    priority: 0.7,
  }))
}

// /product/sitemap/0.xml contains:
// <url>
//   <loc>https://acme.com/product/a</loc>
//   <lastmod>2024-01-01</lastmod>
//   <changefreq>weekly</changefreq>
//   <priority>0.7</priority>
// </url>
// <url>
//   <loc>https://acme.com/product/b</loc>
//   ...
// </url>

One sitemap per locale, listed in robots.txt

ids do not have to be numbers — any string works, and it becomes the filename. This yields sitemap/en.xml, sitemap/de.xml and sitemap/fr.xml. Because Next.js does not emit a sitemap index, robots.ts is where the three files get advertised to crawlers; forgetting that step is why generated chunks often go undiscovered.

Example · typescript
// app/[locale]/articles/sitemap.ts
import type { MetadataRoute } from 'next'
import { getArticles } from '@/app/lib/articles'

const LOCALES = ['en', 'de', 'fr'] as const

export async function generateSitemaps() {
  return LOCALES.map((locale) => ({ id: locale }))
}

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const locale = await props.id

  const articles = await getArticles(locale)

  return articles.map((article) => ({
    url: `https://acme.com/${locale}/articles/${article.slug}`,
    lastModified: article.updatedAt,
  }))
}

// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/' },
    sitemap: [
      'https://acme.com/en/articles/sitemap/en.xml',
      'https://acme.com/en/articles/sitemap/de.xml',
      'https://acme.com/en/articles/sitemap/fr.xml',
    ],
  }
}

Discussion

  • Be the first to comment on this lesson.