Type something to search...
Next.js generateSitemaps

Next.js generateSitemaps

A single sitemap.xml file works fine right up until it doesn't. The sitemap protocol caps every file at 50,000 URLs (and 50MB uncompressed), and long before you hit that ceiling, a five-figure <urlset> becomes slow to generate, slow to crawl, and awkward to reason about. If you're running an e-commerce catalog, a large content site, or anything with a product or listing count that grows without bound, a single sitemap file is a ticking time bomb rather than a long-term solution.

generateSitemaps is the Next.js answer to that problem: instead of one sitemap.ts producing one file, it lets a single route produce as many sitemap files as your data needs, each addressed by an id in the URL. It's a small API — one function, one return type — but the moment your catalog crosses a few tens of thousands of items, it's the difference between a working sitemap and a broken one.

What generateSitemaps Actually Does

Normally, a sitemap.ts file exports a single default function that returns an array of URL entries, and Next.js serves that array as /sitemap.xml. generateSitemaps changes the shape of that contract. Instead of your default export producing the final list directly, you add a second export — generateSitemaps — that returns an array of small objects, each with just an id:

// app/product/sitemap.ts
export async function generateSitemaps() {
  // Fetch the total number of products and calculate the number of sitemaps needed
  return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }];
}

Each object in that array becomes its own generated sitemap file, and Next.js calls your default sitemap function once per id, passing that id in so you know which slice of data to return:

// app/product/sitemap.ts (continued)
import type { MetadataRoute } from "next";
import { BASE_URL } from "@/app/lib/constants";

export default async function sitemap(props: {
  id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
  const id = await props.id;
  // Google's limit is 50,000 URLs per sitemap
  const start = Number(id) * 50000;
  const end = start + 50000;
  const products = await getProducts(
    `SELECT id, date FROM products WHERE id BETWEEN ${start} AND ${end}`,
  );

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

Notice the id prop arrives as a Promise<string>, not a plain value — this is a change introduced in Next.js 16 to align with how params and search params are now handled everywhere else in the App Router (they're all promises you await, rather than synchronous values). Forgetting the await here is one of the most common mistakes with this API: without it, id is a Promise object, and Number(id) on a Promise gives you NaN, silently breaking your range math and either serving an empty sitemap or, worse, one that duplicates entries from index 0.

Where the Generated Sitemaps Live

Each sitemap generated this way is served at a predictable, indexed URL: /.../sitemap/[id].xml. For a route at app/product/sitemap.ts, that means:

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

There's a subtlety worth calling out from the version history: prior to Next.js 15, the development-mode URL for these files didn't match the production URL — you'd see something like /product/sitemap.xml/1 locally, but /product/sitemap/1.xml once deployed. As of 15.0.0, Next.js made these consistent between dev and prod, which matters if you ever hardcoded a URL pattern based on what you observed in next dev before that fix landed. If you're on an older version and something looks off between local testing and production, this mismatch is the first thing to check.

Calculating How Many Sitemaps You Need

The example in the official docs hardcodes [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }], but in a real application you almost never want a fixed array — your product count changes over time, and a fixed list either wastes empty sitemap files or, more dangerously, silently drops products once your catalog outgrows the hardcoded range. The more robust pattern is to compute the count from your actual data:

// app/product/sitemap.ts
const SITEMAP_SIZE = 50000;

export async function generateSitemaps() {
  const totalProducts = await getProductCount();
  const sitemapCount = Math.ceil(totalProducts / SITEMAP_SIZE);

  return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }));
}

Using Math.ceil here matters more than it looks. If you have 100,001 products and use Math.floor or integer division instead, you'll compute exactly 2 sitemaps and silently lose the 100,001st product — and every product after it, as your catalog grows further, since the count will keep landing just past a clean multiple of 50,000. This is the single most common bug in real-world generateSitemaps implementations: a rounding function that quietly drops the tail end of a dataset. Always round up.

A Complete Worked Example: Blog Posts Instead of Products

The official example is product-catalog shaped, but the pattern applies identically to any large, growing collection — blog posts, forum threads, user profiles, job listings. Here's the same approach applied to a blog with a growing archive:

// app/blog/sitemap.ts
import type { MetadataRoute } from "next";
import { db } from "@/lib/db";

const POSTS_PER_SITEMAP = 50000;

export async function generateSitemaps() {
  const { count } = await db.post.count();
  const sitemapCount = Math.ceil(count / POSTS_PER_SITEMAP);

  return Array.from({ length: sitemapCount }, (_, i) => ({ id: i }));
}

export default async function sitemap(props: {
  id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id);
  const posts = await db.post.findMany({
    skip: id * POSTS_PER_SITEMAP,
    take: POSTS_PER_SITEMAP,
    orderBy: { updatedAt: "desc" },
    select: { slug: true, updatedAt: true },
  });

  return posts.map((post) => ({
    url: `https://example.com/blog/${post.slug}`,
    lastModified: post.updatedAt,
    changeFrequency: "weekly",
    priority: 0.7,
  }));
}

Using skip/take (or your ORM's equivalent pagination primitives) instead of a raw BETWEEN id AND id range query, like the official example uses, is usually the better approach once your primary keys aren't perfectly sequential — soft-deleted rows, UUID primary keys, or any gap in your ID sequence will throw off a naive BETWEEN range and produce sitemaps with fewer entries than expected, or overlapping entries between adjacent sitemap files.

How Search Engines Discover the Split Sitemaps

Splitting your sitemap into multiple files doesn't automatically tell Google or Bing that all of them exist — you still need a way to point crawlers at the full set. The cleanest approach is to generate a sitemap index that lists every split file, and reference that index (not the individual files) from robots.txt:

// app/sitemap-index.xml/route.ts
import { BASE_URL } from "@/app/lib/constants";

export async function GET() {
  const productSitemaps = await getProductSitemapIds(); // e.g. [0, 1, 2, 3]

  const sitemapEntries = productSitemaps
    .map(
      (id) =>
        `<sitemap><loc>${BASE_URL}/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">
${sitemapEntries}
</sitemapindex>`;

  return new Response(xml, {
    headers: { "Content-Type": "application/xml" },
  });
}
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: "*", allow: "/" },
    sitemap: "https://example.com/sitemap-index.xml",
  };
}

Submitting only the index URL to Google Search Console (rather than every individual sitemap file) is both simpler to maintain and matches how Google expects large sites to be organized — the index itself counts toward the 50,000-URL limit too, but since it only lists sitemap files rather than pages, that ceiling is effectively never a concern in practice.

Common Mistakes

Forgetting to await the id prop. As covered above, id arrives as a Promise<string> as of Next.js 16. Treating it as a synchronous value produces NaN in any arithmetic and silently corrupts your range calculations rather than throwing a visible error.

Using Math.floor or integer division instead of Math.ceil. This quietly truncates your last (partial) sitemap, dropping the tail of your dataset without any error or warning — the kind of bug that only surfaces when someone notices new products aren't showing up in Google Search Console weeks later.

Hardcoding the sitemap count. The official example's [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }] is illustrative, not a production pattern. Compute the count from your actual row count every time generateSitemaps runs, or your sitemap silently stops covering new content the moment your catalog crosses the hardcoded boundary.

Assuming this replaces caching or revalidation strategy. generateSitemaps controls how many files get created and what data each one serves — it says nothing about how often those files regenerate. If your product catalog changes frequently, you still need to think about revalidation (time-based or on-demand) for the underlying sitemap function, the same as any other data-dependent route.

Not accounting for range drift between adjacent sitemaps. If you use raw ID ranges (BETWEEN start AND end) against a table where rows can be deleted, a product that existed in sitemap 3 at generation time might shift into a different sitemap's range after a deletion elsewhere in the table, causing duplicate or missing entries across regenerations. Pagination via skip/take ordered by a stable, append-only column (like createdAt) avoids this class of bug entirely.

Key Takeaways

ConcernWhat to do
When to reach for thisOnce a single sitemap would exceed roughly 50,000 URLs
Computing sitemap countAlways Math.ceil(total / 50000), never Math.floor
Reading the id propAlways await it — it's a Promise<string> as of Next.js 16
Pagination strategyPrefer skip/take over raw ID-range queries once rows can be deleted
Search engine discoveryGenerate and submit a sitemap index, not each split file individually
Dev vs. prod URLsConsistent since Next.js 15; expect a mismatch only on older versions

generateSitemaps is one of those APIs you'll never touch on a small project and can't do without on a large one. The mental model is simple — one function decides how many files exist, another decides what each one contains — but getting the boundary math and pagination strategy right is what separates a sitemap that quietly scales with your content from one that quietly loses it.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper