Generating a `sitemap.xml` in Next.js

Generate a sitemap in the Next.js App Router with the sitemap file convention: the returned fields, real data from a database or CMS, per-section sitemaps, and when the file is built.

8 min read

A sitemap lists the URLs you want search engines to crawl, and the App Router generates one from a file. Put sitemap.ts in the app directory, return an array of URL objects, and Next.js serves valid sitemap XML at /sitemap.xml. A static sitemap.xml file works too, but the generated version is what keeps up with your content.

The examples were verified with Next.js 16.3 in an App Router project, reading the responses from a production build that uses the default caching model, with the Cache Components flag off. The file runs on the server during the build, never in the browser.

The smallest generated sitemap

The default export returns an array. The type is exported by Next.js, so the field names and allowed values are checked as you write them.

typescripttypescript
// app/sitemap.ts
import type { MetadataRoute } from 'next'
 
export default function sitemap(): MetadataRoute.Sitemap {
  return [
    { url: 'https://acme.com', lastModified: new Date('2026-08-01'), priority: 1 },
    { url: 'https://acme.com/about', lastModified: new Date('2026-08-01') },
  ]
}

Requesting /sitemap.xml returns an XML document with one url element per entry, served as application/xml. A Date is rendered as an ISO timestamp, while a plain string is passed through as written, which matters if you prefer a date-only format.

Only the url field is required. Everything else is optional, and adding fields you cannot maintain is worse than leaving them out.

A static file works as well. Put a hand-written sitemap.xml in the app directory when the site has a handful of fixed pages, and switch to the generated form when the list starts changing.

The fields worth setting

Four optional fields cover most sites, and two of them are frequently misused.

FieldUse it for
lastModifiedThe real last edit time, from your data
changeFrequencyA hint, one of always through never
priorityRelative importance inside your own site, 0 to 1
alternatesLanguage versions of the same page

The priority field is relative to your own pages, not a ranking signal against other sites. Setting every page to 1 tells a crawler nothing, so either vary it meaningfully or leave it out.

Building it from real data

A hardcoded list goes stale the day someone publishes a post. The useful version reads your content source and maps it, with the base URL kept in one place.

typescripttypescript
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getPublishedPosts } from '@/app/lib/posts'
 
const base = 'https://acme.com'
const staticRoutes = ['', '/about', '/blog']

Those two constants are the parts you maintain by hand. Everything else comes from the content source, so publishing a post updates the sitemap without anyone editing this file.

typescripttypescript
// app/sitemap.ts
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPublishedPosts()
  return [
    ...staticRoutes.map((path) => ({ url: `${base}${path}` })),
    ...posts.map((post) => ({
      url: `${base}/blog/${post.slug}`,
      lastModified: post.updatedAt,
    })),
  ]
}

The generated file now lists the static routes followed by every published post, with each post carrying its own modification date. Filter in the query rather than in the map, so drafts and unlisted records never reach the file.

Read the base URL from an environment variable in a real project, the same value used for canonical URLs and metadataBase. A sitemap that points at the wrong host is worse than no sitemap.

Splitting by section

A nested file serves the path of its own segment, which is a simple way to keep a large site organized without any extra API.

typescripttypescript
// app/docs/sitemap.ts
import type { MetadataRoute } from 'next'
 
export default function sitemap(): MetadataRoute.Sitemap {
  return [{ url: 'https://acme.com/docs/intro', lastModified: '2026-08-01' }]
}

This is served at /docs/sitemap.xml, independent of the root sitemap. Each file is its own route, so a slow documentation query does not delay the main sitemap.

Splitting by section also keeps ownership clear. The team that owns the docs owns the file that lists them, and a mistake in one Next.js sitemap cannot break the others.

Once one section outgrows 50,000 URLs, the format itself requires splitting, which is what generateSitemaps exists for. A nested file and that function solve different problems, so reach for the nested file first.

When the file is generated

The sitemap route is a Route Handler under the hood, prerendered at build and cached, which is why the build output lists it as a static entry. That is the behavior you want, since crawlers hit it rarely and the content changes on deploy.

Reading request-time data changes that. A sitemap that awaits a request-time API is marked as rendered on demand in the build output, so it runs per request.

texttext
├ ○ /sitemap.xml
├ ƒ /live/sitemap.xml

The circle is the prerendered file and the other symbol marks the on-demand one. Enabling Cache Components changes this table, so read the legend your own build prints. If your sitemap depends on content that changes between deploys, prefer revalidation over per-request rendering, which the article on Next.js cache revalidation covers.

Tell crawlers where it is

A sitemap nobody knows about does very little, because crawlers do not guess paths beyond the conventional one at the site root. The robots file is the standard place to announce it, and its sitemap field accepts several entries.

typescripttypescript
// app/robots.ts
import type { MetadataRoute } from 'next'
 
export default function robots(): MetadataRoute.Robots {
  return {
    rules: { userAgent: '*', allow: '/' },
    sitemap: ['https://acme.com/sitemap.xml', 'https://acme.com/docs/sitemap.xml'],
  }
}

The generated robots.txt now ends with one Sitemap line per entry, which was confirmed by requesting the file from a production build. Submitting the URL in a search console is still worth doing for the reporting, but the robots file is what every crawler reads first.

Keep the two in sync. Every Next.js sitemap route you add should appear in this list, since nothing discovers a nested sitemap automatically.

Common mistakes

Each of these produces a Next.js sitemap that validates and still works against you, which is why they survive review.

  • Writing relative paths, which the sitemap format does not accept.
  • Listing pages that carry a noindex tag, which sends conflicting signals about the same URL.
  • Setting lastModified to the current date on every build, so every URL looks freshly edited.
  • Including query-string variants of the same page instead of the canonical path.
  • Leaving a hardcoded list in place after the content moved into a database.
  • Building URLs from a hardcoded domain that no longer matches the site, which is easy to miss because the file still validates.
Rune AI

Rune AI

Key Insights

  • app/sitemap.ts returns an array typed as MetadataRoute.Sitemap and is served at /sitemap.xml.
  • URLs must be absolute, so build them from one base URL constant.
  • lastModified accepts a Date or a string, and a Date is rendered as an ISO timestamp.
  • A nested sitemap file serves its own segment path, such as /docs/sitemap.xml.
  • The route is prerendered and cached unless it reads request-time data.
RunePowered by Rune AI

Frequently Asked Questions

Where does the sitemap file go?

In the app directory. A file at app/sitemap.ts is served at /sitemap.xml, and a file in a nested segment such as app/docs/sitemap.ts is served at /docs/sitemap.xml.

Do the URLs have to be absolute?

Yes. The sitemap format requires fully qualified URLs, so build them from a base URL constant rather than writing relative paths.

Is the sitemap regenerated on every request?

No. It is prerendered at build time and cached unless it uses a request-time API or a dynamic config option, in which case the build output marks it as rendered on demand.

How many URLs can one sitemap hold?

The format allows 50,000 URLs per file. Past that, split it into several sitemaps, which the generateSitemaps function is designed for.

Conclusion

A sitemap in the App Router is one file that returns an array of URL objects. Keep the URLs absolute and built from a base constant, include only pages you want indexed, add lastModified where you actually track it, and split the file once the catalog outgrows a single sitemap.