Back to Blog

Next.js Sitemap: The Complete Setup Guide (2026)

Generate an XML sitemap in Next.js for App Router and Pages Router, including dynamic routes from a database or CMS. Code examples and gotchas included.

I
Indexly Team
· · 10 min read

Next.js Sitemap: The Complete Setup Guide (2026)

A Next.js sitemap tells search engines every page you want crawled, and Next.js gives you a built-in way to generate one without third-party tools. This guide covers a working nextjs sitemap for both the App Router and the Pages Router, including the part most tutorials skip: dynamic routes pulled from a database or CMS.

By the end you'll have a sitemap.xml that lists static pages, dynamic pages, and scales to large sites. You'll also know where the built-in approach breaks down and what to do about it.

Table of contents

The App Router way: app/sitemap.ts

If you're on the App Router, this is the cleanest path. Create a file at app/sitemap.ts that exports a default function returning MetadataRoute.Sitemap — an array of objects with a url, an optional lastModified, and optional changeFrequency and priority fields. Next.js detects this file and serves the result automatically at /sitemap.xml. You don't wire up a route or write XML by hand.

Here's a minimal static example:

import type { MetadataRoute } from 'next'

const baseUrl = 'https://example.com'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.7,
    },
    {
      url: `${baseUrl}/pricing`,
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 0.8,
    },
  ]
}

Deploy that, hit /sitemap.xml, and you'll see valid XML. The url field must be an absolute URL — relative paths won't validate. That's the whole contract for static pages. The interesting part is everything you can't hardcode.

Adding dynamic routes (from a DB or CMS)

Most real sites have pages that don't exist as files: blog posts, products, docs articles, user profiles. Those come from a database or a CMS, and your sitemap needs to include them.

The sitemap function can be async, so you can fetch your data and map it into entries. Combine the dynamic entries with your static routes:

import type { MetadataRoute } from 'next'

const baseUrl = 'https://example.com'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  // Fetch slugs from your DB, CMS, or API
  const posts = await fetch(`${baseUrl}/api/posts`).then((res) => res.json())

  const postEntries: MetadataRoute.Sitemap = posts.map((post: {
    slug: string
    updatedAt: string
  }) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.6,
  }))

  const staticEntries: MetadataRoute.Sitemap = [
    { url: baseUrl, lastModified: new Date(), priority: 1 },
    { url: `${baseUrl}/pricing`, lastModified: new Date(), priority: 0.8 },
  ]

  return [...staticEntries, ...postEntries]
}

A few things to get right here. Use each record's real updatedAt for lastModified so search engines know when a page actually changed — don't just stamp new Date() on everything. Filter out drafts and anything marked noindex before you map. And make sure your fetch or query is fast; this runs when the sitemap is generated, and a slow call here slows down that response.

For more on the trade-off between generating this at build time versus on each request, see our breakdown of dynamic vs static sitemaps.

Splitting large sitemaps into a sitemap index

A single sitemap file is capped at 50,000 URLs and 50MB uncompressed. Past that, you split into multiple sitemaps referenced by a sitemap index.

Next.js handles this with a generateSitemaps export. At a high level: you export a generateSitemaps function that returns an array of objects, each with an id. Your default sitemap function then receives that id as a parameter and returns only the slice of URLs for that chunk. Next.js produces files like /sitemap/0.xml, /sitemap/1.xml, and so on.

import type { MetadataRoute } from 'next'

const baseUrl = 'https://example.com'
const URLS_PER_SITEMAP = 50000

export async function generateSitemaps() {
  const total = await getProductCount()
  const pages = Math.ceil(total / URLS_PER_SITEMAP)
  return Array.from({ length: pages }, (_, id) => ({ id }))
}

export default async function sitemap({
  id,
}: {
  id: number
}): Promise<MetadataRoute.Sitemap> {
  const start = id * URLS_PER_SITEMAP
  const products = await getProducts(start, URLS_PER_SITEMAP)

  return products.map((product: { slug: string }) => ({
    url: `${baseUrl}/products/${product.slug}`,
    lastModified: new Date(),
  }))
}

Check the Next.js docs for the exact shape if you adopt this, since the chunking logic is yours to define. The key idea: paginate your data by id, and each chunk becomes its own sitemap file.

The Pages Router way

If you're still on the Pages Router, there's no built-in sitemap.ts. You generate the XML yourself. The common pattern is a page at pages/sitemap.xml.tsx that uses getServerSideProps to write XML directly to the response and renders nothing:

import type { GetServerSideProps } from 'next'

const baseUrl = 'https://example.com'

function generateSiteMap(slugs: string[]) {
  return `<?xml version="1.0" encoding="UTF-8"?>
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url><loc>${baseUrl}</loc></url>
    ${slugs
      .map((slug) => `<url><loc>${baseUrl}/blog/${slug}</loc></url>`)
      .join('')}
  </urlset>`
}

export const getServerSideProps: GetServerSideProps = async ({ res }) => {
  const posts = await fetch(`${baseUrl}/api/posts`).then((r) => r.json())
  const slugs = posts.map((p: { slug: string }) => p.slug)

  res.setHeader('Content-Type', 'text/xml')
  res.write(generateSiteMap(slugs))
  res.end()

  return { props: {} }
}

export default function SiteMap() {
  return null
}

This runs on every request, so the sitemap stays current. The downside is you're hand-writing XML, which means you own escaping and formatting. If you'd rather not run it per request, generate the same XML in a build script and write it to public/sitemap.xml instead — just know it goes stale until the next build.

Using the next-sitemap package

next-sitemap is a popular community package that generates a sitemap and a robots.txt at build time. You configure it with a next-sitemap.config.js file and run it as a postbuild step.

The config is minimal in spirit — you set your site URL and a few options:

/** @type {import('next-sitemap').IConfig} */
module.exports = {
  siteUrl: 'https://example.com',
  generateRobotsTxt: true,
}

Then add a postbuild script in package.json:

"scripts": {
  "postbuild": "next-sitemap"
}

It crawls your built output and emits the files into public/. Check the package's own docs for the full option list — keep the config above as a starting point, not a complete reference. The important trade-off: because it runs at build time, the sitemap reflects your site as of the last deploy. For frequently changing content, that's a limitation, not a feature.

Common Next.js sitemap gotchas

A few things trip up almost everyone:

  • Build-time vs request-time freshness. A sitemap generated at build time freezes until your next deploy. Publish a post on Monday, deploy on Friday, and search engines won't see it in your sitemap until Friday. For content that changes often, generate at request time, or use ISR / on-demand revalidation so the sitemap regenerates when data changes.
  • Absolute URLs are required. Every <loc> must be a full URL with protocol and host. Relative paths fail validation. Always build from a baseUrl constant.
  • Trailing-slash consistency. Decide whether your URLs end in a slash and make the sitemap match your actual routes. Mismatches create duplicate-URL confusion. Keep it aligned with your trailingSlash config.
  • Exclude drafts and noindex pages. Don't list pages you've set to noindex or haven't published. A sitemap is a list of pages you want indexed — filter before you map.
  • The 50,000-URL / 50MB limit. One file maxes out at 50,000 URLs or 50MB uncompressed. Past either, split into a sitemap index.

When to use a hosted sitemap instead

Writing sitemap code in Next.js is fine, and for many teams it's the right call. But it comes with ongoing costs. The sitemap goes stale between deploys unless you wire up revalidation. It needs upkeep every time routes change. And it only covers your Next.js app — it won't include a separate marketing site, a docs subdomain, or a headless store living on another stack.

That's the case for a hosted sitemap. Indexly crawls your live site, generates an always-current sitemap, and hosts it on a permanent URL — no code to maintain. When you add or remove pages, Indexly catches the change on the next crawl and can alert you to new and removed URLs. Because it crawls the rendered site, it picks up pages regardless of which framework or CMS produced them.

This isn't the only option, and if you're happy owning sitemap code, keep doing that. But if you'd rather not maintain it — or you have pages outside Next.js — a hosted sitemap takes the whole job off your plate. The same approach works for automating sitemaps across any stack, not just Next.js.

Whichever route you choose, once your sitemap exists you still need to submit it to Google Search Console so it gets picked up.

FAQ

Where does Next.js serve the sitemap from?

When you create app/sitemap.ts in the App Router, Next.js automatically serves the result at /sitemap.xml. You don't define a route or set headers. For the Pages Router there's no built-in handler, so you create the route yourself, commonly at pages/sitemap.xml.tsx.

How do I add dynamic routes to a Next.js sitemap?

Make the sitemap function async, fetch your slugs from a database, CMS, or API, then map each record into a sitemap entry with an absolute URL and a real lastModified date. Spread those dynamic entries alongside your static routes in the returned array.

Why is my sitemap missing new pages?

Most often it was generated at build time and hasn't regenerated since your last deploy. Switch to a request-time sitemap, add ISR or on-demand revalidation, or use a hosted sitemap that re-crawls on a schedule. Also confirm new pages aren't filtered out as drafts.

Do I need the next-sitemap package?

No. The App Router's built-in app/sitemap.ts covers static and dynamic pages without any package. next-sitemap is useful mainly if you want a build-time sitemap plus an auto-generated robots.txt in one step, or you're on an older setup. For most App Router projects, the native approach is enough.

How big can one sitemap file be?

A single sitemap is limited to 50,000 URLs and 50MB uncompressed. If your site exceeds either, split it into multiple sitemaps referenced by a sitemap index. In the App Router you do this with generateSitemaps, which produces numbered files like /sitemap/0.xml.

The bottom line

For most Next.js apps on the App Router, app/sitemap.ts returning MetadataRoute.Sitemap is all you need — static and dynamic pages, served automatically at /sitemap.xml. The Pages Router takes a bit more manual XML, and next-sitemap handles build-time generation if you want it.

The real question is upkeep. Code-based sitemaps need maintenance and go stale between deploys, and they stop at your app's boundary. If a stale or partial sitemap is part of why Google isn't indexing your pages, a hosted sitemap solves it without code. Indexly crawls your live site, keeps the sitemap fresh on a permanent URL, and tells you when pages appear or disappear.

Want to skip the maintenance? Start free at indexly.dev.

I

Indexly Team

Writing about SEO, sitemaps, and how to get every page indexed by Google.

Enjoyed this post?

Get our next one delivered to your inbox — no spam, ever.

Back to Blog

Ready to get your site fully indexed?

Get started free