How to Add a Sitemap in Next.js
The App Router turns a single app/sitemap.ts file into a valid /sitemap.xml. Here's how to generate it from your real data, set the right fields, split large sites, and get it indexed.

TL;DR — In the Next.js App Router, add a app/sitemap.ts file that default-exports a function returning MetadataRoute.Sitemap — an array of URL objects. Next serves it at /sitemap.xml automatically. Generate the entries from your real data source so new pages show up on their own, then reference it in robots.txt and submit it once in Google Search Console.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap {
return [{ url: 'https://example.com', lastModified: new Date() }];
}
That's the whole convention. The rest of this guide is about doing it well — pulling entries from your content, setting the optional fields correctly, splitting large sitemaps, and making sure search engines find it. It's the hands-on companion to the broader Next.js SEO guide.
Why a sitemap matters for indexing
A sitemap is a machine-readable list of the URLs you want indexed, with optional hints about when each one last changed. It doesn't guarantee indexing — Google decides that — but it gives crawlers a complete, authoritative map of your site instead of making them discover pages by following links.
That matters most where link discovery is weakest: brand-new pages with few inbound links, deep pages buried several clicks from the homepage, and large sites where crawl budget is finite. For a small static site the payoff is modest, but for a blog or catalog that adds pages over time, a sitemap generated from your data is the difference between new content getting found in hours versus weeks.
Step 1: Create app/sitemap.ts
The App Router has a file convention for this. Drop a sitemap.ts (or .js) file in your app/ directory that default-exports a function returning MetadataRoute.Sitemap. Next builds /sitemap.xml from it — no XML, no third-party package, no manual <head> wiring.
The function can be synchronous or async, which is the key feature: you can await your CMS, database, or filesystem and build the URL list from real data.
Step 2: Generate entries dynamically from your content
The point of doing this in code is that static sitemaps go stale. Pull your routes from the same source your pages render from, so adding a post automatically adds a sitemap entry.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/posts';
const BASE_URL = 'https://example.com';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const staticRoutes: MetadataRoute.Sitemap = [
{ url: BASE_URL, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
{ url: `${BASE_URL}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.8 },
{ url: `${BASE_URL}/pricing`, lastModified: new Date(), changeFrequency: 'monthly', priority: 0.5 },
];
const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${BASE_URL}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'monthly',
priority: 0.7,
}));
return [...staticRoutes, ...postRoutes];
}
A few things worth getting right:
- Use absolute URLs. Entries must be fully qualified (
https://example.com/...), not relative paths. Keep the origin in one constant. - List the canonical URL only. One entry per page, matching the canonical in your metadata. Don't list trailing-slash and non-trailing-slash variants, or both
wwwand apex. - Pull
lastModifiedfrom real timestamps. A meaningfulupdatedAtis a useful crawl hint;new Date()on every build just tells crawlers "everything changed," which they learn to ignore.
Step 3: Understand lastModified, changeFrequency, and priority
Each entry supports three optional fields beyond url:
| Field | Type | What it does |
|---|---|---|
lastModified |
Date | string |
When the page last meaningfully changed. The most useful hint — keep it honest. |
changeFrequency |
'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never' |
How often the page tends to change. A weak hint. |
priority |
0.0–1.0 |
Relative importance within your own site. Also a weak hint. |
Be realistic about what these do. Google has said for years that it largely ignores changeFrequency and priority and leans on lastModified — and only when it's accurate. Invest in a truthful lastModified, set the other two roughly if you like (homepage high, deep utility pages low), and don't agonize over them. They won't rank a weak page higher.
Step 4: Split sitemaps for large sites
The sitemap spec caps a single file at 50,000 URLs or 50 MB uncompressed. If you're near either limit, split into multiple sitemaps with a sitemap index.
Next supports this via generateSitemaps. You return a list of ids, and Next calls your sitemap function once per id, serving each chunk at /sitemap/[id].xml:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { getProductCount, getProducts } from '@/lib/products';
const BASE_URL = 'https://example.com';
const PER_SITEMAP = 50_000;
export async function generateSitemaps() {
const total = await getProductCount();
const pages = Math.ceil(total / PER_SITEMAP);
return Array.from({ length: pages }, (_, id) => ({ id }));
}
export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
const start = id * PER_SITEMAP;
const products = await getProducts({ offset: start, limit: PER_SITEMAP });
return products.map((product) => ({
url: `${BASE_URL}/products/${product.slug}`,
lastModified: product.updatedAt,
}));
}
Below 50,000 URLs, don't bother — a single sitemap is simpler and just as effective. Most blogs and SaaS sites never come close.
Step 5: Reference it in robots.txt and submit to Search Console
A sitemap that nothing points to is harder for crawlers to find. Two steps close the loop:
1. Reference it from robots.txt. In the App Router, add a app/robots.ts that points at the sitemap. If you split into multiple files, point at the index URL (/sitemap.xml), which Next generates for you.
// app/robots.ts
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/' },
sitemap: 'https://example.com/sitemap.xml',
};
}
2. Submit it once in Google Search Console. Go to Indexing → Sitemaps, enter sitemap.xml, and submit. You only do this once; from then on GSC re-fetches it periodically and reports any parsing errors or pages it chose not to index. (If you deploy on Vercel, /sitemap.xml is served automatically from your sitemap.ts — there's nothing extra to configure.)
Common mistakes
- Forgetting dynamic routes. A
[slug]route renders fine, but if yoursitemap.tsonly lists static pages, none of those dynamic URLs appear. Generate them from the same data the pages use — that's the entire reason to do this in code. - Including noindex or private pages. Don't list pages marked
noindex, auth-gated dashboards, thank-you pages, or staging routes. A sitemap is a list of pages you want indexed; mixing in pages you don't sends a contradictory signal. - Letting a static sitemap go stale. A hand-written
public/sitemap.xmldrifts out of sync over time. Theapp/sitemap.tsconvention regenerates on each build (or per request, for dynamic data), so it stays current on its own. - Listing non-canonical or wrong-origin URLs. Trailing-slash duplicates,
httpvshttps, query-string versions, or straylocalhostentries all muddy your signals. Pick the canonical production form and list only that.
Frequently asked questions
Do I need a sitemap if my pages are already linked internally?
Not strictly — crawlers can discover well-linked pages on their own. But a sitemap helps for new pages, deep pages, and large sites, and it's the cleanest way to declare your canonical URLs to Search Console. For a content site that grows over time, it's worth the few lines of code.
What's the difference between sitemap.ts and sitemap.xml?
app/sitemap.ts is the source file you write; /sitemap.xml is the route Next.js generates from it and serves to crawlers. You author TypeScript, your users (and Googlebot) get valid XML. You never write the XML by hand.
Does Next.js generate the sitemap automatically?
It generates the XML from your code, not magically from your routes. Next won't crawl your app/ directory and infer URLs — you decide which pages to include by returning them from the sitemap function. The convention handles the XML serialization and serving; you handle the URL list.
How do I add a sitemap in the Pages Router instead?
The App Router convention (app/sitemap.ts) doesn't apply to the Pages Router. There, the common pattern is an API route or a getServerSideProps page at pages/sitemap.xml.tsx that writes the XML to the response manually. If you're starting fresh, use the App Router — the built-in convention is far less code.
Conclusion
Adding a sitemap in Next.js is a small file with an outsized payoff: one app/sitemap.ts that generates URLs from your real content, an honest lastModified on each entry, a robots.txt reference, and a one-time Search Console submission. Keep it generated from data so it never goes stale, and don't over-invest in changeFrequency and priority — lastModified is the field that earns its keep. If you want a second set of eyes on how your pages read to crawlers and AI assistants, SEOAgent runs inside your coding agent to audit sitemaps, metadata, and internal links right in the repo, and you can spot-check a page's AI readiness with the OKF checker. For the full picture of building search-ready sites in code, start with the SEO for developers guide.
Put SEO on autopilot in your own editor
SEOAgent runs as a free skill inside Claude Code, Cursor, and Codex — on the model you already pay for. Audit, plan, and write SEO content right in your repo, with every change reviewed before it ships. No second AI subscription.
Get SEOAgent free