sitemap.xml / sitemap.ts

Serve /sitemap.xml from a static XML file or a default-exported function returning URL entries.

Signature
export default function sitemap(props?: { id: Promise<string> }): MetadataRoute.Sitemap | Promise<MetadataRoute.Sitemap>
  • Added in Next.js 13.3.0

Parameters

ParameterTypeDescription
$props.idoptionalPromise<string>Only passed when the file also exports generateSitemaps. Resolves to the id of the sitemap shard currently being generated, so you can slice your data set.
$urlstringAbsolute URL of the page, including protocol and host. Emitted as <loc>.
$lastModifiedoptionalstring | DateWhen the page last changed. Emitted as <lastmod>.
$changeFrequencyoptional'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'Hint about how often the page changes. Emitted as <changefreq>.
$priorityoptionalnumberRelative importance of this URL within your own site, 0.0 to 1.0. Emitted as <priority>.
$alternates.languagesoptionalLanguages<string>Map of hreflang code to the URL of that language's version. Emitted as xhtml:link rel="alternate" elements.
$imagesoptionalstring[]Absolute image URLs for this page, emitted as image:image entries for Google image sitemaps.
$videosoptionalVideos[]Video entries (title, thumbnail_loc, description and the other video sitemap fields) emitted as video:video elements.

Return value

MetadataRoute.Sitemap | Promise<MetadataRoute.Sitemap>An array of entry objects, one per URL, which Next.js serialises into the Sitemaps XML format and serves at the segment's sitemap URL.

Errors

The build fails if the file exports something other than a default function returning an array of entry objects; a relative url produces an invalid document rather than an error.

Changelog

  • PHP 16.0.0The id prop passed alongside generateSitemaps is now a promise that resolves to a string, so it must be awaited.
  • PHP 14.2.0alternates.languages added for localized sitemaps.
  • PHP 13.4.14changeFrequency and priority added to entries.
  • PHP 13.3.0sitemap file convention introduced.

Drop a static sitemap.xml in the app directory and Next.js serves it verbatim. Rename it to sitemap.ts and it becomes a Route Handler: export a default function returning an array, and Next.js writes the XML for you.

When to reach for the code version

  • Your routes come from a CMS or database, so the list cannot be checked into git.
  • You need hreflang alternates generated from a locale list.
  • You have more than 50,000 URLs and must shard with generateSitemaps.

What trips people up

The generated route is cached by default. Unless you touch a request-time API or set a segment config such as export const revalidate = 3600, the function runs once at build time and the sitemap freezes at that snapshot — new posts never appear until the next deploy. Calling new Date() at module scope has the same problem: every entry gets the build timestamp.

URLs must be absolute. A leading-slash path produces a document crawlers reject, and nothing warns you. Nested files (app/products/sitemap.ts) serve at /products/sitemap.xml and are not collected into an index automatically, so list every shard in robots.ts.

Example

Example · typescript
import type { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://acme.com',
      lastModified: '2025-04-06T15:02:24.021Z',
      changeFrequency: 'yearly',
      priority: 1,
    },
    {
      url: 'https://acme.com/about',
      lastModified: '2025-04-06T15:02:24.021Z',
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    {
      url: 'https://acme.com/blog',
      lastModified: '2025-04-06T15:02:24.021Z',
      changeFrequency: 'weekly',
      priority: 0.5,
    },
  ]
}

When to use it

  • Emit every published blog post URL from the CMS with its real lastModified date
  • Give a multi-locale marketing site hreflang alternates so Google serves the right language
  • Shard a 400,000-product catalogue into eight sitemaps with generateSitemaps
  • Point crawlers at the canonical URL set after a domain migration

Frequently asked questions

Why is my Next.js sitemap not updating with new pages?

Because the route is statically generated at build time by default. Add a segment config such as `export const revalidate = 3600` to app/sitemap.ts, or use an uncached data call, so the function re-runs after deploy instead of serving the build-time snapshot.

Can I have more than one sitemap in the app directory?

Yes, two ways: place additional files in nested segments (app/products/sitemap.ts serves /products/sitemap.xml), or export generateSitemaps from one file to emit /segment/sitemap/[id].xml shards. Next.js does not build an index of them, so reference each one from robots.ts.

Do sitemap URLs have to be absolute?

Yes. The Sitemaps protocol requires a full URL with protocol and host in <loc>, and Next.js writes whatever string you return without rewriting it, so a value like '/about' produces a document search engines silently reject.

More examples

Database-backed sitemap that refreshes hourly

Without `export const revalidate`, the fetch of posts happens once during `next build` and the served XML never changes. With it, the route regenerates at most once an hour, so posts published after the deploy still show up. Note `'monthly' as const` — without it TypeScript widens the literal to `string` and the array stops matching MetadataRoute.Sitemap.

Example · typescript
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getPosts } from '@/app/lib/posts'

export const revalidate = 3600

const BASE_URL = 'https://acme.com'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await getPosts()

  const routes: MetadataRoute.Sitemap = ['', '/about', '/blog'].map(
    (route) => ({
      url: `${BASE_URL}${route}`,
      changeFrequency: 'weekly',
      priority: route === '' ? 1 : 0.7,
    })
  )

  return [
    ...routes,
    ...posts.map((post) => ({
      url: `${BASE_URL}/blog/${post.slug}`,
      lastModified: post.updatedAt,
      changeFrequency: 'monthly' as const,
      priority: 0.5,
    })),
  ]
}

Localized entries with hreflang alternates

Each `<url>` gains two `<xhtml:link rel="alternate" hreflang="...">` children pointing at the Spanish and German versions. The default-language URL stays in `<loc>`; the alternates are siblings of it, not separate entries, which is what stops Google treating the translations as duplicate content.

Example · typescript
// app/sitemap.ts
import type { MetadataRoute } from 'next'

const BASE_URL = 'https://acme.com'
const PATHS = ['', '/about', '/blog']

export default function sitemap(): MetadataRoute.Sitemap {
  return PATHS.map((path) => ({
    url: `${BASE_URL}${path}`,
    lastModified: '2025-04-06T15:02:24.021Z',
    alternates: {
      languages: {
        es: `${BASE_URL}/es${path}`,
        de: `${BASE_URL}/de${path}`,
      },
    },
  }))
}

Sharding a large catalogue with generateSitemaps

The four ids produce four routes: /product/sitemap/0.xml through /product/sitemap/3.xml. Since Next.js 16 the `id` prop is a promise, so `await props.id` is required and it arrives as a string. Multiplying that string coerces it to a number, which is why the official docs write `const id = await props.id` and then `id * 50000` directly; the `Number(...)` call here only makes the numeric type explicit for the rest of the function. Nothing links these four files together automatically; list all four in robots.ts.

Example · typescript
// app/product/sitemap.ts
import type { MetadataRoute } from 'next'
import { getProducts } from '@/app/lib/products'

const BASE_URL = 'https://acme.com'

export async function generateSitemaps() {
  return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }]
}

export default async function sitemap(props: {
  id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
  const id = Number(await props.id)
  // Google's limit is 50,000 URLs per sitemap
  const start = id * 50000
  const end = start + 50000
  const products = await getProducts(start, end)

  return products.map((product) => ({
    url: `${BASE_URL}/product/${product.id}`,
    lastModified: product.updatedAt,
  }))
}

Discussion

  • Be the first to comment on this lesson.