If you searched "Next.js sitemap" you have probably hit a wall of articles all recommending the same plugin: next-sitemap. It is a fine package, but it is not necessary anymore. Since Next.js 13.3, the App Router supports XML sitemaps as a first-class Metadata Route, and most sites do not need a plugin at all. This article walks through what the built-in approach can actually do, where it breaks, and how to handle the harder cases (large sites, dynamic content, sitemap index files) without adding a dependency.
I am going to assume you are on the App Router (the app/ directory). If you are still on the Pages Router, the situation is different and this article does not apply. Migrate or use a plugin.
The minimum viable sitemap
Create app/sitemap.ts (or .js):
import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: 'https://example.com/about',
lastModified: new Date('2026-01-15'),
changeFrequency: 'monthly',
priority: 0.8,
},
];
}That is it. Visit /sitemap.xml and Next.js generates valid XML from your function's return value. No XML strings to escape, no encoding bugs, no manual urlset boilerplate.
A few things worth knowing:
- The function can be async. You can fetch from a database, CMS, or filesystem inside it.
- The file lives at the route root (app/sitemap.ts), not nested. Nested sitemap files are possible but require the sitemap index pattern, covered below.
changeFrequencyandpriorityare technically valid in the protocol but Google has stated for years that it largely ignores them. I include them for non-Google search engines and out of habit, not because they matter much.
Generating from a CMS or database
Real sites have content that changes. The pattern that works for almost every CMS:
import { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const blogEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: 'https://example.com/blog/' + post.slug,
lastModified: new Date(post.updatedAt),
changeFrequency: 'monthly',
priority: 0.7,
}));
return [
{
url: 'https://example.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: 'https://example.com/blog',
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
},
...blogEntries,
];
}This is the pattern I use in production. The sitemap is regenerated on every deploy if your getAllPosts reads at build time, and on every request if it reads from a database directly.
The revalidate gotcha
Here is the trap that costs people a week of head-scratching.
If getAllPosts reads from a database or external API, the sitemap is dynamic by default. That means it is regenerated on every request. For a site with thousands of posts, that is expensive and unnecessary.
The fix is the same revalidate export you use on regular pages:
export const revalidate = 3600; // regenerate at most once per hourThis caches the sitemap and rebuilds it at most once per hour, regardless of traffic. For a typical content site, an hour or even a day is fine. Googlebot does not check your sitemap more than once a day in most cases anyway.
If your content is built at deploy time (markdown files in your repo, MDX from a content folder), no revalidate is needed. The sitemap is regenerated on each deploy.
When you cross 50,000 URLs: the sitemap index
The sitemap protocol caps individual sitemap files at 50,000 URLs and 50MB uncompressed. Past that, you need a sitemap index, a "sitemap of sitemaps" that points to multiple individual files.
Next.js supports this through the same Metadata Route system, but it is less obvious than the single-file case. Here is the pattern:
Step 1: Generate multiple sitemap files using generateSitemaps
Replace app/sitemap.ts with this structure:
import { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
const URLS_PER_SITEMAP = 5000;
export async function generateSitemaps() {
const posts = await getAllPosts();
const totalSitemaps = Math.ceil(posts.length / URLS_PER_SITEMAP);
return Array.from({ length: totalSitemaps }, (_, i) => ({ id: i }));
}
export default async function sitemap(
{ id }: { id: number }
): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const start = id * URLS_PER_SITEMAP;
const end = start + URLS_PER_SITEMAP;
return posts.slice(start, end).map((post) => ({
url: 'https://example.com/blog/' + post.slug,
lastModified: new Date(post.updatedAt),
}));
}Next.js automatically generates /sitemap/0.xml, /sitemap/1.xml, and so on, plus a /sitemap.xml index that references all of them. You do not need to write the index file yourself.
Step 2: Verify the index in your browser
Visit /sitemap.xml. You should see something like:
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap/0.xml</loc>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap/1.xml</loc>
</sitemap>
</sitemapindex>I usually pick a chunk size of 5,000 to 10,000 even though the spec allows 50,000. Smaller chunks make GSC reports easier to read. You can see indexing rates per sitemap file rather than mixed across one giant file.
Splitting by content type (recommended for mixed sites)
If you have multiple distinct content types (blog posts, product pages, category pages), splitting by type rather than by chunk index makes Search Console reports far more useful.
The tradeoff is that you give up the auto-generation of generateSitemaps and write each typed sitemap as its own route. The pattern looks like:
// app/sitemap-blog.xml/route.ts
import { getAllPosts } from '@/lib/posts';
export async function GET() {
const posts = await getAllPosts();
const urls = posts
.map((post) => '<url><loc>https://example.com/blog/' + post.slug +
'</loc><lastmod>' + post.updatedAt + '</lastmod></url>')
.join('\n');
const xml =
'<?xml version="1.0" encoding="UTF-8"?>' +
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">' +
urls +
'</urlset>';
return new Response(xml, {
headers: { 'Content-Type': 'application/xml' },
});
}Then your top-level app/sitemap.ts lists each typed sitemap as the index:
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: 'https://example.com/sitemap-pages.xml' },
{ url: 'https://example.com/sitemap-blog.xml' },
{ url: 'https://example.com/sitemap-products.xml' },
];
}For sites with mixed content types, I prefer this. The indexing rate per content type is the most useful single metric you can extract from GSC, and the chunk-by-id approach hides it.
Common pitfalls
A few things I see go wrong on real Next.js deploys.
Forgot to set metadataBase
If app/layout.tsx does not export a metadataBase, Next.js will emit warnings about absolute URLs in Open Graph and similar tags. It does not affect the sitemap itself (the sitemap function returns absolute URLs explicitly), but the warnings clutter your build logs. Set it once:
export const metadata = {
metadataBase: new URL('https://example.com'),
// ...
};Mixing https and https-www in URLs
If your canonical domain is https://example.com but the sitemap emits https://www.example.com, you will end up with the "Submitted URL not selected as canonical" status in Search Console. Pick one and use it everywhere: sitemap, canonical tags, internal links, everything.
Ignoring lastmod accuracy
Setting lastModified: new Date() on every URL means every URL appears to have been updated right now. Google notices and starts ignoring your lastmod dates entirely. Use the actual content modification time, not the current time.
Including non-canonical or noindex pages
The sitemap should only contain URLs you want indexed. Filter out drafts, paginated archives, search results, and tagged URLs before mapping. The cost of leaving them in is small per URL, but at scale it dilutes your indexing signal.
When to use a plugin instead
The built-in approach handles most cases. The cases where I would still reach for next-sitemap or similar:
- You need image, video, or news sitemap extensions and do not want to write the XML by hand
- You want automatic exclusion based on noindex meta tags from page metadata
- You want sitemap generation as a build step that emits static files (rather than rendering through Next.js routes)
For everything else, even sites with hundreds of thousands of URLs, the Metadata Route approach is enough.