Skip to content

Next.js App Router Sitemaps: sitemap.ts, Index Files, and Dynamic Routes

Next.js App Router Sitemaps: sitemap.ts, Index Files, and Dynamic Routes

You do not need next-sitemap to ship an XML sitemap on the App Router.

app/sitemap.ts is a file convention. Next.js turns the array you return into /sitemap.xml. Use a static sitemap.xml only if the URL list never changes. Split files when you cross 50,000 URLs, not because a tutorial told you to.

This site still runs next-sitemap after build because of a /category/ rewrite. That is a constraint, not a recommendation. If you are starting now, start with sitemap.ts.

What a Next.js sitemap actually is

A sitemap is a list of canonical URLs you want crawlers to discover. Google reads loc and, when the dates are honest, lastmod. It ignores changefreq and priority.

It is not a ranking lever. It does not force indexing. It is a discovery file.

You still need it if the site is new, large, or thin on external links. I submit one the same week I add the property in Search Console. OpenGraph.xyz, which I sold, lived on organic search. The sitemap was how Google found pages that were not yet in anyone's link graph.

HTML sitemaps are a different object. This post is only XML.

The App Router file convention

From Next.js 13.3 onward, sitemaps are metadata files, same family as robots.ts and opengraph-image.tsx.

App Router sitemap files

  • app/sitemap.ts/sitemap.xml
  • app/blog/sitemap.ts/blog/sitemap.xml
  • app/product/sitemap.ts/product/sitemap/[id].xml
  • app/robots.ts/robots.txt

Two shapes:

  1. app/sitemap.xml. You write the XML by hand
  2. app/sitemap.ts (or .js). You export a default function that returns { url, lastModified? }[]

TypeScript gets MetadataRoute.Sitemap. The function can be async. The old snippet circulating from 2023 awaits inside a non-async function. That does not run.

sitemap.ts is cached like other metadata routes unless you opt into request-time APIs. For a blog that updates on deploy, that is what you want.

Static sitemap.xml for sites that barely change

A five-page marketing site does not need code. Put XML in app/sitemap.xml:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://acme.com</loc>
    <lastmod>2026-08-16</lastmod>
  </url>
  <url>
    <loc>https://acme.com/about</loc>
    <lastmod>2026-08-16</lastmod>
  </url>
  <url>
    <loc>https://acme.com/work</loc>
    <lastmod>2026-08-16</lastmod>
  </url>
</urlset>

I skip changefreq and priority. Google will not read them. lastmod is worth keeping if you will actually update it when the page changes. A hardcoded date you never touch is worse than omitting the field.

The moment you add a blog, this file is a chore. Move to sitemap.ts.

sitemap.ts: static routes plus dynamic slugs

This is the pattern I want on a site like Yuurrific: hardcoded marketing URLs, then every published post.

import type { MetadataRoute } from 'next';
import { allPosts } from 'content-collections';
 
const SITE = 'https://www.yuurrific.com';
 
const staticRoutes = [
  '',
  '/about',
  '/projects',
  '/write',
  '/work-with-me',
  '/uses',
];
 
export default function sitemap(): MetadataRoute.Sitemap {
  const routes = staticRoutes.map((route) => ({
    url: `${SITE}${route}`,
    lastModified: new Date(),
  }));
 
  const posts = allPosts
    .filter((post) => post.status === 'Published')
    .map((post) => ({
      url: `${SITE}${post.slug}`,
      lastModified: post.date,
    }));
 
  return [...routes, ...posts];
}

post.slug on this repo is already /dev/the-slug. If your CMS stores a nested filesystem path that is not the public URL, map it here. That mapping is the whole job. next-sitemap's transform exists because I used to leak /category/dev/slug into the XML. Native sitemap.ts lets you write the public URL once and never rewrite it.

List only canonical, 200, indexable URLs. No /login, no /api, no search result pages, no ?page=2 if those paginated URLs are noindex.

lastModified can be a Date or an ISO string. Use the post's real date, not new Date() on every build, if you want Google to treat lastmod as a signal. Google has said it uses lastmod only when the dates match what it sees on the page.

Multiple sitemap files, and how Google finds them

One sitemap may contain 50,000 URLs and stay under 50 MB uncompressed. Below that, one file is simpler.

When you split, Next.js does not invent a sitemap index for you. Nested files are just more URLs:

FileServed at
app/sitemap.ts/sitemap.xml
app/blog/sitemap.ts/blog/sitemap.xml
app/product/sitemap.ts/product/sitemap.xml

Point at every file from robots.ts. Google also accepts multiple Sitemap lines in robots.txt.

import type { MetadataRoute } from 'next';
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: ['/api/', '/login'],
    },
    sitemap: [
      'https://www.yuurrific.com/sitemap.xml',
      'https://www.yuurrific.com/blog/sitemap.xml',
    ],
  };
}

Split by section when the data sources differ: marketing pages from a hardcoded list, posts from MDX, products from a database. That is easier to debug than one function that fetches three systems.

next-sitemap builds a sitemap index (/sitemap.xml pointing at /sitemap-0.xml). Native nested files do not. List every sitemap URL in robots.ts (or in robots.txt). Search Console can also take each child sitemap URL. I do not maintain a fake index by hand.

generateSitemaps for tens of thousands of URLs in one section

Nested files are the wrong tool when one route family is huge. AgentLookup.sg has tens of thousands of agent profiles. That is one app/agent/sitemap.ts with generateSitemaps, not 38,000 lines in /sitemap.xml.

Google's per-file cap is 50,000. Chunk below that so a bad query cannot blow the file.

In Next.js 16, id is a Promise<string>. Await it. Coerce to a number before you do math.

import type { MetadataRoute } from 'next';
 
const SITE = 'https://agentlookup.sg';
const PAGE_SIZE = 45_000;
 
export async function generateSitemaps() {
  const total = await countPublishedAgents();
  const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
 
  return Array.from({ length: pages }, (_, id) => ({ id }));
}
 
export default async function sitemap(props: {
  id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
  const page = Number(await props.id);
  const start = page * PAGE_SIZE;
  const agents = await getPublishedAgents(start, PAGE_SIZE);
 
  return agents.map((agent) => ({
    url: `${SITE}/agent/${agent.slug}`,
    lastModified: agent.updatedAt,
  }));
}

Those files are served at /agent/sitemap/0.xml, /agent/sitemap/1.xml, and so on. Next.js 15 made that URL the same in development and production. On 13.x you would open /agent/sitemap.xml/0 in next dev and get confused. Upgrade before you debug the path.

Add each generated URL to robots.ts, or list /agent/sitemap/0.xml through N if the count is stable. If the page count changes every deploy, generate the sitemap array in robots.ts from the same countPublishedAgents() helper.

Do not put raw SQL with string-interpolated ids in the sitemap function. The Next.js docs still show that in places. Use a parameterized query.

When I still use next-sitemap

next-sitemap is a postbuild scanner. It walks the prerendered routes in .next and writes public/sitemap.xml. That is useful when:

  • Public URLs are not the filesystem paths (transform on this site rewrites /category/...)
  • You want an automatic sitemap index once you pass sitemapSize
  • You already generate robots.txt from that config and do not want a second source of truth

It is a poor fit when URLs exist only at request time. ISR pages that were never prerendered will not show up unless you add a additionalPaths hook. Native sitemap.ts can query the same source the page uses.

Yuurrific still has this in package.json:

{
  "scripts": {
    "build": "next build",
    "postbuild": "next-sitemap --config next-sitemap.config.js"
  }
}

I would not add that to a greenfield App Router app. I also would not rip it out of this repo in the same PR as a redesign. The rewrite is real.

If you keep next-sitemap, set siteUrl, exclude /api and auth routes, and put transform next to any middleware rewrite. Do not also ship app/sitemap.ts that lists different URLs. Two generators means Search Console will show whichever file you submitted, and you will debug the other one.

What to put in the file, and what to leave out

Put:

  • Canonical https URLs
  • lastmod you can defend
  • Image or video extensions only if you actually have that media on the URL (Next supports images and videos on the sitemap object)

Leave out:

  • Redirects and trailing-slash duplicates
  • Parameterized sort/filter URLs
  • Anything noindex
  • Preview / draft posts

Localized sites can set alternates.languages on each entry. Next emits xhtml:link hreflang nodes. Only do that if those locales are real pages, not a language switcher that 302s.

FAQ

How do I create a sitemap in Next.js App Router?

Add app/sitemap.ts, export a default function that returns { url, lastModified }[], and open /sitemap.xml after a build. Or drop a static app/sitemap.xml if the list is tiny and frozen.

Do I still need next-sitemap?

No, not for a typical App Router site. Use it if you need a postbuild scan, a sitemap index, or a transform for rewritten paths. Native sitemap.ts is the default in 2026.

How do I generate a sitemap index in Next.js?

Nested sitemap.ts files do not create an index. List every sitemap URL in app/robots.ts. generateSitemaps creates /…/sitemap/[id].xml files you list the same way. next-sitemap will write a sitemapindex if you set generateIndexSitemap: true.

What is the Next.js sitemap URL limit?

50,000 URLs per sitemap file, 50 MB uncompressed, same as Google's cap. Use generateSitemaps or nested files before you hit it.

Why is my Next.js sitemap empty in development?

generateSitemaps URLs changed across versions. On Next 15+ they match production: /section/sitemap/0.xml. Fetch that path, not /sitemap.xml/0. Also confirm the default export is async if you await data.

What to do next

Write app/sitemap.ts. Hit /sitemap.xml locally. Then submit that URL in Search Console. One submission. Google will recrawl it. You do not ping a deprecated endpoint.

If pages stay "Discovered, not indexed," that is not a sitemap bug. Start with the indexing issues checklist.