Next.js App Router has built-in sitemap support that handles the XML formatting for you — you just return an array of URL objects. This guide covers how to set it up, how to handle dynamic routes like blog posts and product pages, and a few gotchas that aren't obvious from the docs.
Basic Setup
Create app/sitemap.ts and export a default function that returns a MetadataRoute.Sitemap array. Next.js serves this at /sitemap.xml automatically, with the correct application/xml content type.
// app/sitemap.ts
import type { 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-05-01'),
changeFrequency: 'monthly',
priority: 0.8,
},
];
}A note on changeFrequency and priority: Google officially ignores both fields. Include them if you want, but don't spend time tuning them — only lastModified is actually used when it's accurate.
Adding Dynamic Routes
For pages with dynamic routes — blog posts, product pages, user profiles — fetch the slugs or IDs from your database or CMS inside the sitemap function. Since the function can be async, you can call any data source directly:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json() as Promise<{ slug: string; updatedAt: string }[]>;
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://example.com';
const posts = await getPosts();
const staticRoutes: MetadataRoute.Sitemap = [
{ url: baseUrl, lastModified: new Date() },
{ url: `${baseUrl}/blog`, lastModified: new Date() },
];
const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
}));
return [...staticRoutes, ...postRoutes];
}Use the actual updatedAt field from your database for lastModified. Setting it to new Date() (i.e. now) for every post on every request means Google learns to ignore your lastmod — it sees every URL updating constantly even when content hasn't changed.
Setting metadataBase
Next.js needs to know your production domain to generate absolute URLs correctly. Set it in your root layout:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://example.com'),
// ...
};Without this, Next.js will warn about relative URLs in metadata during the build, and your sitemap may generate incorrect localhost URLs in development.
Splitting Into Multiple Sitemaps
If you have more than 50,000 URLs, you need to split into a sitemap index. Next.js supports this through route-based splitting — create multiple sitemap files at different paths and a sitemap index that references them.
For most Next.js projects, the simplest approach is to use the next-sitemap package, which handles splitting, index generation, and robots.txt generation automatically. Configure it in next-sitemap.config.js and run it 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: ['/dashboard/*', '/auth/*', '/api/*'],
};Excluding Private Routes
Dashboard pages, auth flows, and API routes should never appear in your sitemap. With the native App Router approach, simply don't include them in the array you return. With next-sitemap, use the exclude array.
For auth and dashboard routes, also add a layout-level noindex so even if they're discovered by following links, they won't be indexed:
// app/dashboard/layout.tsx
export const metadata: Metadata = {
robots: { index: false, follow: false },
};Verifying Your Sitemap
After deploying, check:
- Open
https://yourdomain.com/sitemap.xmlin your browser. It should render as valid XML with a list of URLs. If it shows a parse error or returns HTML, check yourapp/sitemap.tsfor syntax errors. - Check that all URLs use your production domain (not
localhost). If they do, check yourmetadataBasesetting. - Run a crawl of the sitemap URLs to verify each returns HTTP 200. Any that return 404 or redirect indicate a route configuration issue.
- Submit to Google Search Console under Indexing → Sitemaps.
Keeping It Fresh
With the native App Router approach, your sitemap is generated dynamically on request, so it's always current — new blog posts appear immediately without any manual regeneration. If you're using static export or next-sitemap as a post-build step, you'll need to redeploy or run the sitemap generation script whenever you add significant new content to make sure new routes are included.