CoreCited
Technical

Next.js sitemap: copy-ready app/sitemap.ts for static, dynamic and 50,000+ URL sites

Build a Next.js sitemap with app/sitemap.ts: static and CMS-driven examples, generateSitemaps for large sites, honest lastModified dates, and robots.ts.

AlexFounder & Developer10 min read

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.

Key takeaways
1app/sitemap.ts returns MetadataRoute.Sitemap; Next.js serves it at /sitemap.xml.
2Pass each page's real last-edit date as lastModified, or leave it out. Never new Date() for every URL.
3generateSitemaps splits a large sitemap into /.../sitemap/[id].xml files. In Next.js 16, id is a Promise<string>.
4Next.js does not build a sitemap index. A small route handler at app/sitemap.xml/route.ts does.
5The sitemap is cached by default. Redeploy on publish, or set revalidation.
50000
URLs per sitemap file at most: the reason to split
sitemaps.org
18%
of sitemaps with lastmod had it set wrong, usually to the generation date
Bing study, Feb 2023
713
URLs in nextjs.org's own sitemap, with 49 different lastmod values
fetched 11 Oct 2026
0
sitemap index files Next.js generates for you
Next.js docs, GitHub #77304

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 (static)
// 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 (from a CMS or database)
// 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 (split into files)
// 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)
// 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].

The folder rule and split sitemaps
The protocol says a sitemap at /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
// 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 with honest dates
// 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 goes wrong, and the fix
Each row is sourced in the sections above. Toggle columns.
What you seeFix
lastModified: new Date() everywhereEvery URL has the same date, changing on each buildUse each record's updatedAt, or omit the field
Relative or localhost URLs<loc>/blog/post</loc> or http://localhost:3000 in productionBuild URLs from a production base URL set at build time
Expecting an index from generateSitemaps/sitemap.xml returns 404Add app/sitemap.xml/route.ts
id used as a number in Next.js 16Type error, or wrong offsetsNumber(await props.id)
A stale sitemapNew posts missing until the next deployRebuild on publish, or set revalidation
Drafts, noindex pages or redirects listedSearch Console: URLs not followed or not accessibleFilter to published, indexable, canonical URLs
Two handlers for /sitemap.xmlapp/sitemap.ts and app/sitemap.xml/route.ts both presentKeep one
Middleware or proxy catching the pathThe sitemap redirects to a login or locale pageExclude sitemap and robots paths from the matcher
Myth
The docs example is production-ready.
What is true
It shows the API. Its new Date() dates and its string arithmetic in generateSitemaps are not what you want in production.
Myth
generateSitemaps gives you a sitemap index at /sitemap.xml.
What is true
It does not. The docs document only /.../sitemap/[id].xml, and a maintainer confirmed the gap in August 2026.
Myth
Setting priority: 1 on key pages helps them rank.
What is true
Google says it doesn't use priority at all.

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.

The docs example stamps every URL with new Date(). Pass the date the page changed, or pass nothing.
Before you ship app/sitemap.ts
0/8

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.

Keep reading

Sources

[1]sitemap.xml (metadata file conventions) — Next.js docs (v16.4), updated 18 August 2026
[2]generateSitemaps — Next.js docs (v16.4), updated 28 September 2026
[3]robots.txt (metadata file conventions) — Next.js docs (v16.4), read 11 October 2026
[4]Caching and Revalidating (Previous Model) — Next.js docs, updated 18 August 2026
[5]Route Segment Config — Next.js docs, updated 1 October 2026
[6]generateSitemaps() does not generate a root sitemap.xml (issue #77304) — vercel/next.js on GitHub, closed 31 August 2026
[7]Sitemap index (discussion #61025) — vercel/next.js on GitHub, opened 23 January 2024
[8]Build and submit a sitemap — Google Search Central, updated 8 July 2026
[9]Manage your sitemaps with sitemap index files — Google Search Central, updated 10 December 2025
[10]Sitemaps ping endpoint is going away — Google Search Central Blog (Gary Illyes), 26 June 2023
[11]Sitemaps XML format — sitemaps.org, updated 21 November 2016
[12]The Importance of Setting the "lastmod" Tag in Your Sitemap — Bing Webmaster Blog (Fabrice Canel), 1 February 2023
[13]next-sitemap README — iamvishnusankar/next-sitemap on GitHub, read 11 October 2026
[14]next-sitemap package metadata — npm registry, read 11 October 2026 (latest 4.2.3, published 6 September 2023)

Find out where you actually stand

One real question, real AI engines, and the answer they gave — including who was named in it. No account, no card.