A Next.js sitemap in the App Router is one file: app/sitemap.ts, which returns an array of URLs that Next.js serves as /sitemap.xml[1]. The basics take five minutes. The details are where most sites go wrong: the official example stamps every URL with new Date(), generateSitemaps does not create an index, and the file is cached, so a CMS-driven sitemap can go stale. This guide gives copy-ready TypeScript for each case, checked against the current docs.
Once it is deployed, run your domain through the free sitemap validator. It reads your robots.txt, follows any index, and flags identical dates, redirects and blocked URLs.
Sources: the protocol[11]; Bing’s study of sitemaps it knew about[12]; the Next.js docs[2] and GitHub issue[6]. The nextjs.org count is what its sitemap served when we fetched it.
1. A static sitemap for a small site
The sitemap.(js|ts) convention exports a default function that returns an array of URL objects. If you use TypeScript, the MetadataRoute.Sitemap type checks each entry[1]. For a site of a few dozen pages, a hand-kept list is fine:
// app/sitemap.ts: served at /sitemap.xml
import type { MetadataRoute } from "next";
const BASE = "https://www.example.com"; // absolute URLs only
export default function sitemap(): MetadataRoute.Sitemap {
return [
// The date the page last changed in a way that matters, kept by hand.
{ url: `${BASE}/`, lastModified: "2026-09-30" },
{ url: `${BASE}/pricing`, lastModified: "2026-08-14" },
// No reliable date? Leave lastModified out. Google says that is fine.
{ url: `${BASE}/about` },
];
}Two choices here differ from the docs example on purpose. Every url is absolute, because Google asks for “fully-qualified, absolute URLs” and crawls them “exactly as listed”[8]. And there is no changeFrequency or priority: Next.js supports both, but Google “still doesn’t use the changefreq or priority elements at all”[10]. If you only need a fixed list, you can also put a plain sitemap.xml file in the app folder[1].
2. A dynamic sitemap from a CMS or database
Most sites build the list from their data. The function can be async, so fetch the published pages and map them:
// app/sitemap.ts: pages from a CMS or database
import type { MetadataRoute } from "next";
import { getPublishedPosts } from "@/lib/cms"; // your data layer
const BASE = process.env.SITE_URL ?? "https://www.example.com";
// Regenerate at most once an hour (the caching model without Cache Components).
export const revalidate = 3600;
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Only what you want in search: published, indexable, canonical URLs.
const posts = await getPublishedPosts(); // newest first
return [
{ url: `${BASE}/` }, // an aggregate page: no honest single date
{ url: `${BASE}/blog`, lastModified: posts[0]?.updatedAt },
...posts.map((post) => ({
url: `${BASE}/blog/${post.slug}`,
lastModified: post.updatedAt, // the post's last real edit, not new Date()
images: post.coverImage ? [post.coverImage] : undefined,
})),
];
}Three things in that file matter more than they look.
The dates. lastModified comes from the record’s own updatedAt. The docs example uses new Date() on every URL, which writes the time the sitemap was generated. The protocol is explicit that the date “must be set to the date the linked page was last modified, not when the sitemap is generated”[11]. Google warns that if dates do not “consistently match reality”, “eventually we’re not going to believe you anymore”, and it says pages you cannot date, such as a home page that only aggregates others, can simply leave the field out[10]. That is why the home page above has no date. The lastmod guide covers what counts as a real change.
The caching. The docs say sitemap.js “is a special Route Handler that is cached by default unless it uses a Request-time API or dynamic config option”[1]. Without that line, the sitemap is frozen at build time, and new posts appear only after a deploy. Under the caching model without Cache Components, a Route Handler can export revalidate to refresh on a timer[4]. If your app enables Cache Components, Next.js 16 removes revalidate as a segment option[5]; follow the Cache Components caching docs instead, or simply rebuild when you publish.
The filter. Only list what you want in search. Google: “Include the URLs in your sitemap that you want to see in Google’s search results”[8]. Drafts, scheduled posts, noindex pages and URLs that redirect do not belong.
The images field adds an image sitemap entry, and videos and alternates.languages add video and hreflang entries; each is in the MetadataRoute.Sitemap type[1].
3. More than 50,000 URLs: generateSitemaps
A sitemap file may hold at most 50,000 URLs and 50 MB uncompressed[11]. Past that, Next.js offers two routes: nest sitemap.ts files in different route segments, such as app/sitemap.xml and app/products/sitemap.xml, or use generateSitemaps[1]. Here is the second, for a product catalogue:
// app/products/sitemap.ts: served at /products/sitemap/0.xml, /products/sitemap/1.xml ...
import type { MetadataRoute } from "next";
import { countProducts, getProducts } from "@/lib/db";
const BASE = "https://www.example.com";
const PER_FILE = 45_000; // under the 50,000 URL limit, with room to spare
export async function generateSitemaps() {
const files = Math.max(1, Math.ceil((await countProducts()) / PER_FILE));
return Array.from({ length: files }, (_, id) => ({ id }));
}
// Next.js 16: id arrives as a Promise<string>. Convert it before doing maths.
// Next.js 15 passes { id } directly, also a string at runtime: use Number(id) there.
export default async function sitemap(props: { id: Promise<string> }): Promise<MetadataRoute.Sitemap> {
const id = Number(await props.id);
const products = await getProducts({ offset: id * PER_FILE, limit: PER_FILE });
return products.map((p) => ({
url: `${BASE}/products/${p.slug}`,
lastModified: p.updatedAt,
}));
}The files are served at /.../sitemap/[id].xml, here /products/sitemap/0.xml and up[2]. Two version notes. Since Next.js 16, “the id values returned from generateSitemaps are now passed as a promise that resolves to a string”[2], so Number(await props.id) before multiplying. The docs’ own example multiplies the string directly, which TypeScript rejects. In Next.js 15 the id is passed without a promise, but it is still a string at runtime: the 15.5 route loader slices it from the URL, minus the .xml, and passes it on unconverted. So convert it on either version. And since Next.js 15, the URLs are the same in development and production[2].
Page by offset, not by ID range. The docs example selects WHERE id BETWEEN start AND end; IDs with gaps give uneven files, and BETWEEN includes both ends, so a file can hold one URL more than you planned. The 45,000 above leaves room for that kind of slip.
4. The sitemap index Next.js does not make
With generateSitemaps, nothing answers at /sitemap.xml. A GitHub issue reporting exactly that was closed on 31 August 2026 by a maintainer, who wrote that “generateSitemaps() currently documents only /sitemap/[id].xml” and pointed to the open sitemap index discussion[6], which dates from January 2024[7]. So write the index yourself. A route handler in a folder named sitemap.xml answers at that path:
// app/sitemap.xml/route.ts: a sitemap index at /sitemap.xml
// Delete app/sitemap.ts if you add this: both would answer /sitemap.xml.
import { countProducts } from "@/lib/db";
const BASE = "https://www.example.com";
const PER_FILE = 45_000; // must match app/products/sitemap.ts
export const revalidate = 3600;
export async function GET() {
const productFiles = Math.max(1, Math.ceil((await countProducts()) / PER_FILE));
const sitemaps = [
`${BASE}/blog/sitemap.xml`, // from app/blog/sitemap.ts
...Array.from({ length: productFiles }, (_, i) => `${BASE}/products/sitemap/${i}.xml`),
];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemaps.map((loc) => ` <sitemap><loc>${loc}</loc></sitemap>`).join("\n")}
</sitemapindex>`;
return new Response(xml, {
headers: { "Content-Type": "application/xml; charset=utf-8" },
});
}The index sits at the root, so every child is in the same directory or lower, as Google requires[9]. Submit the index alone; Google accepts one index “as a way to submit many sitemaps at once”[9].
/catalog/sitemap.xml can only list URLs under /catalog/[11], and Google applies that “unless you submit your sitemap through Search Console”[8]. A file at /products/sitemap/0.xml lists /products/… URLs, which sit above its folder. Submitting in Search Console lifts the rule for what you submit. If you want the files to be correct for every crawler, a rewrite in next.config can serve them from the root, for example /sitemap-products-0.xml to /products/sitemap/0.xml, with the index listing the root URLs. Next.js does not document that combination, so check the live URLs after you deploy.5. robots.ts: point crawlers at the sitemap
app/robots.ts returns a MetadataRoute.Robots object, with rules per user agent and a sitemap field that takes one URL or an array[3]:
// app/robots.ts: served at /robots.txt
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: "*", allow: "/", disallow: ["/api/", "/admin/"] },
// Optional: a training crawler you choose to keep out.
{ userAgent: "GPTBot", disallow: "/" },
],
// Full URL. Every crawler reads this line, including ones you never submit to.
sitemap: "https://www.example.com/sitemap.xml",
};
}The Sitemap: line matters more since Google retired its sitemap ping endpoint in 2023; robots.txt and Search Console are the ways left to submit[10]. Like the sitemap, robots.js is cached by default[3]. Whether to block GPTBot is a separate decision, covered in should you block GPTBot, and the robots.txt generator writes the rules for you.
The Pages Router: next-sitemap
The sitemap.ts convention belongs to the App Router. In the Pages Router, the common choice is the next-sitemap package: a next-sitemap.config.js file and a postbuild script that writes the sitemap and, optionally, robots.txt after each build[13]. Check two defaults before you ship it. autoLastmod defaults to true, and the README’s own transform sets lastmod to new Date().toISOString(), the build time; changefreq defaults to daily and priority to 0.7 on every URL[13]. The latest npm release, 4.2.3, was published on 6 September 2023[14].
// next-sitemap.config.js (Pages Router, run after "next build")
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: "https://www.example.com",
generateRobotsTxt: true,
autoLastmod: false, // the default (true) stamps every URL with the build time
transform: async (config, path) => ({
loc: path,
// Supply a real date per path if you have one; otherwise leave lastmod out.
}),
};
// package.json
// "scripts": { "build": "next build", "postbuild": "next-sitemap" }Common Next.js sitemap mistakes
| What you see | Fix | |
|---|---|---|
| lastModified: new Date() everywhere | Every URL has the same date, changing on each build | Use each record's updatedAt, or omit the field |
| Relative or localhost URLs | <loc>/blog/post</loc> or http://localhost:3000 in production | Build URLs from a production base URL set at build time |
| Expecting an index from generateSitemaps | /sitemap.xml returns 404 | Add app/sitemap.xml/route.ts |
| id used as a number in Next.js 16 | Type error, or wrong offsets | Number(await props.id) |
| A stale sitemap | New posts missing until the next deploy | Rebuild on publish, or set revalidation |
| Drafts, noindex pages or redirects listed | Search Console: URLs not followed or not accessible | Filter to published, indexable, canonical URLs |
| Two handlers for /sitemap.xml | app/sitemap.ts and app/sitemap.xml/route.ts both present | Keep one |
| Middleware or proxy catching the path | The sitemap redirects to a login or locale page | Exclude sitemap and robots paths from the matcher |
A real Next.js sitemap, checked
The Next.js website practises this. When we ran the validator’s checks on nextjs.org on 11 October 2026, its robots.txt declared https://nextjs.org/sitemap.xml, a single file of 713 URLs. Every URL carried a date, with 49 different values; the most common one covered 29% of URLs, a pattern you would expect from pages updated in batches rather than one stamp on everything. All 20 sampled URLs answered 200, and none was blocked for Googlebot, Bingbot or the AI crawlers. That is the bar: valid, dated honestly, and every listed URL live.
For the rest of a Next.js site’s search setup, including what AI crawlers receive from server and client components, see the Next.js SEO guide. If Search Console then reports that the sitemap could not be read, start there.
Questions people ask
How do I add a sitemap to a Next.js app?
In the App Router, create app/sitemap.ts and export a default function that returns an array of objects with url and, optionally, lastModified. Next.js serves it as /sitemap.xml. For a fixed list you can also drop a static sitemap.xml file in the app folder. Then add the sitemap URL to app/robots.ts and submit it in Search Console.
Does generateSitemaps create a sitemap index?
No. generateSitemaps serves split files at /.../sitemap/[id].xml, and the docs document nothing at the root. A GitHub issue reporting that /sitemap.xml returns 404 with generateSitemaps was closed in August 2026 as a duplicate of an open sitemap index request. Build the index yourself with a route handler, as shown on this page.
Should lastModified be new Date()?
No, even though the docs example uses it. new Date() gives every URL the build or request time, so the dates say nothing about real changes. Google uses lastmod only when it is consistently accurate, and Bing found identical dates were the most common lastmod mistake. Pass each page's real last edit, or leave the field out.
Is app/sitemap.ts cached?
Yes. The docs call sitemap.js a special Route Handler that is cached by default unless it uses a Request-time API or a dynamic config option. A sitemap built from a CMS therefore stays as it was at build time until you redeploy or set revalidation. Without Cache Components, export const revalidate works; with Cache Components enabled, that option is removed.
How do I make a sitemap in the Next.js Pages Router?
The sitemap file convention is App Router only. In the Pages Router, the common route is the next-sitemap package, run as a postbuild script with a next-sitemap.config.js file. Turn off autoLastmod, which defaults to true and writes the build time on every URL.
Do I need changeFrequency and priority?
No. Next.js supports them, but Google says it ignores both and Bing says the same. Leave them out; they make the file longer and change nothing.
