I once shipped a 40,000-page e-commerce site with a sitemap that only listed 50 URLs. Nobody noticed for three weeks until organic traffic flatlined. The bug wasn't in my product data. It was in how I generated the sitemap. In this post, I'll walk through three ways to generate a Next.js sitemap, built-in, next-sitemap and power-seo/sitemapand when each one breaks.
Why Sitemaps Are Harder Than They Look
A sitemap seems trivial: loop over your routes, output XML, done. The problem shows up at scale. Google enforces a hard limit of 50,000 URLs (or 50MB uncompressed) per sitemap file. Past that, you need a sitemap index that points to multiple child sitemaps. You also need lastmod, changefreq, and sometimes image or alternate-language annotations and all of it has to regenerate whenever your CMS content changes, not just at build time.
Most tutorials show you the happy path with 10 static routes. Nobody shows you what happens with 80,000 dynamic product pages pulled from a database at build time on a serverless function with a memory ceiling.
Method 1: Next.js's Built-in sitemap.ts
Since Next.js 13.3, the App Router supports a native sitemap.ts file. This is the right starting point for most projects — zero dependencies, fully typed.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetch('https://api.example.com/posts').then((r) => r.json());
const postEntries: MetadataRoute.Sitemap = posts.map((post: any) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly',
priority: 0.7,
}));
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
...postEntries,
];
}
Result: This works beautifully up to a few thousand URLs. But there's a catch — if sitemap() returns more than 50,000 entries, Next.js doesn't automatically split it. You have to manually implement generateSitemaps() to produce a paginated set of sitemap files. It's doable, but it's on you to get the chunking math right, and there's no built-in index file that lists all chunks for Google — you have to hand-roll that too.
Method 2: next-sitemap for Config-Driven Generation
next-sitemap is the long-standing community package for this. Instead of writing code, you write a config file, and it generates static XML files as a post-build step.
// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: 'https://example.com',
generateRobotsTxt: true,
sitemapSize: 5000,
exclude: ['/admin/*', '/api/*'],
};
// package.json
{
"scripts": {
"postbuild": "next-sitemap"
}
}
Result: It automatically splits output into multiple sitemap-0.xml, sitemap-1.xml, etc., plus a sitemap-index.xml, and generates robots.txt referencing it — all with zero manual chunking logic. The tradeoff: it runs as a separate post-build script rather than inside the Next.js data pipeline, so dynamic content that changes between builds (e.g., a CMS webhook triggering new pages) needs an ISR-style revalidation workaround or an on-demand rebuild trigger.
Method 3: Streaming Generation for Very Large Sites
Here's where things get interesting for genuinely large sites — think marketplaces or directories with hundreds of thousands of listings. Loading every URL into memory to build the sitemap array can blow past serverless memory limits or just time out.
This is the actual hard problem, and it's why I started looking at streaming-based sitemap generators instead of array-based ones. @power-seo/sitemap takes this approach it streams entries instead of materializing the whole array, and ships a Next.js adapter so you don't have to hand-write the XML:
// app/sitemap.ts
import { toNextSitemap } from '@power-seo/sitemap/next';
export default async function sitemap() {
const urls = await fetchAllUrls(); // your paginated DB query
return toNextSitemap(urls);
}
Installed with:
npm install @power-seo/sitemap
Result: For a large catalog, the meaningful difference isn't the API surface (all three approaches end up producing valid XML) — it's whether the generation step can process URLs incrementally versus holding the entire set in memory before writing output. If your fetchAllUrls() function is itself paginated/streamed, this pattern avoids the memory spike that array-based approaches hit at high URL counts. You can see the source and options on the project's GitHub repo or check the package on npm.
To be clear: for sites under ~10,000 URLs, this is overkill. The built-in sitemap.ts or next-sitemap will serve you fine.
What I Learned
-
Start with the built-in
sitemap.ts. Don't add a dependency until you hit an actual limit (URL count, memory, or missing feature like auto-generatedrobots.txt). -
Know your URL count before picking a tool. Under 10K: built-in is fine. 10K–50K:
next-sitemaphandles chunking for you. 50K+ with a live database: look at streaming-based generation. -
Test your sitemap in Google Search Console, not just by opening the XML file. A syntactically valid sitemap can still get partially rejected for bad
lastmodformats or duplicate URLs. - Rebuild triggers matter as much as generation logic. A perfect sitemap that's three days stale because nothing triggered a rebuild is worse than a simple one that's current.
If you want to try the streaming approach, here's the repo: https://github.com/CyberCraftBD/power-seo
Let's Discuss
What's the largest URL count you've had to handle in a sitemap, and what broke first memory, build time, or Google's crawl budget? I'm curious whether anyone's rolled their own streaming solution before reaching for a library.
Top comments (1)
The point about rebuild triggers is easy to overlook. A technically perfect sitemap can still become useless if it goes stale, especially on large dynamic sites.