Next.js Sitemap Guide: Static and Dynamic Generation

How to generate sitemaps in Next.js using the built-in App Router support and the next-sitemap package. Covers static generation, dynamic routes, ISR, sitemap indexes, and hreflang.

Next.js gives you two solid paths for sitemap generation. The framework itself has built-in support through the App Router, and the next-sitemap package offers a more feature-rich alternative that runs as a post-build step. Which one you pick depends on how much control you need.

This guide covers both approaches, along with the practical details that trip people up: dynamic routes from a database, incremental static regeneration for sitemaps, sitemap indexes for large sites, and handling internationalized content.

If you need background on sitemap fundamentals first, start with our XML sitemap guide.

Built-In App Router Sitemap Support

Next.js 13.3+ with the App Router added native sitemap generation. You create a sitemap.ts (or sitemap.js) file in your app/ directory, export a default function, and Next.js serves the result as XML at /sitemap.xml.

Static Sitemap

The simplest version: a hardcoded list of URLs.

// app/sitemap.ts
import { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://example.com',
      lastModified: new Date('2026-04-14'),
      changeFrequency: 'weekly',
      priority: 1,
    },
    {
      url: 'https://example.com/about',
      lastModified: new Date('2026-03-01'),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
    {
      url: 'https://example.com/blog',
      lastModified: new Date('2026-04-12'),
      changeFrequency: 'daily',
      priority: 0.9,
    },
  ]
}

This works for small sites with a handful of pages. The sitemap is generated at build time and served as a static file.

Dynamic Sitemap from a Database

Most real sites need to pull URLs from a database or CMS. Make the function async and fetch your data:

// app/sitemap.ts
import { MetadataRoute } from 'next'
import { db } from '@/lib/database'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://example.com'

  // Fetch all published content
  const posts = await db.post.findMany({
    where: { status: 'published' },
    select: { slug: true, updatedAt: true },
  })

  const products = await db.product.findMany({
    where: { active: true },
    select: { slug: true, updatedAt: true },
  })

  // Build URL entries
  const postUrls = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: post.updatedAt,
  }))

  const productUrls = products.map((product) => ({
    url: `${baseUrl}/products/${product.slug}`,
    lastModified: product.updatedAt,
  }))

  return [
    { url: baseUrl, lastModified: new Date() },
    { url: `${baseUrl}/about`, lastModified: new Date('2026-01-15') },
    ...postUrls,
    ...productUrls,
  ]
}

At build time, Next.js calls this function, queries your database, and generates the XML. The output is a standard sitemap that any search engine can parse.

Multiple Sitemaps with generateSitemaps

When your site has more than 50,000 URLs (the sitemap protocol limit), you need to split into multiple sitemaps. Next.js handles this with the generateSitemaps function. Export it alongside your default sitemap function, and Next.js creates a sitemap index automatically.

// app/sitemap.ts
import { MetadataRoute } from 'next'
import { db } from '@/lib/database'

const URLS_PER_SITEMAP = 50000

export async function generateSitemaps() {
  const count = await db.product.count({ where: { active: true } })
  const numSitemaps = Math.ceil(count / URLS_PER_SITEMAP)

  return Array.from({ length: numSitemaps }, (_, i) => ({ id: i }))
}

export default async function sitemap(
  { id }: { id: number }
): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://example.com'

  const products = await db.product.findMany({
    where: { active: true },
    skip: id * URLS_PER_SITEMAP,
    take: URLS_PER_SITEMAP,
    select: { slug: true, updatedAt: true },
    orderBy: { id: 'asc' },
  })

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

This produces a sitemap index at /sitemap.xml with entries like /sitemap/0.xml, /sitemap/1.xml, and so on. Each child sitemap contains up to 50,000 URLs.

ISR Sitemaps: Keeping Them Fresh

By default, the App Router generates your sitemap at build time. If you deploy once a day but add content multiple times a day, your sitemap will be stale between deploys.

Incremental Static Regeneration (ISR) solves this. Add a revalidate export to your sitemap file:

// app/sitemap.ts
import { MetadataRoute } from 'next'
import { db } from '@/lib/database'

// Regenerate the sitemap every hour
export const revalidate = 3600

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://example.com'
  const posts = await db.post.findMany({
    where: { status: 'published' },
    select: { slug: true, updatedAt: true },
  })

  return [
    { url: baseUrl, lastModified: new Date() },
    ...posts.map((post) => ({
      url: `${baseUrl}/blog/${post.slug}`,
      lastModified: post.updatedAt,
    })),
  ]
}

With revalidate = 3600, Next.js serves the cached sitemap for up to one hour. After that, the next request triggers a background regeneration. The stale sitemap is served until the new one is ready.

This is a good balance for most sites. Search engines typically fetch sitemaps every few hours at most, so an hourly revalidation window means your sitemap is always reasonably current without hammering your database on every request.

The next-sitemap Package

The built-in App Router support covers the basics, but the next-sitemap npm package gives you more control. It runs as a post-build script, reads your .next build output, and generates sitemaps from your actual pages.

Setup

npm install next-sitemap

Create a config file in your project root:

// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
  siteUrl: 'https://example.com',
  generateRobotsTxt: true,
  sitemapSize: 5000,
  changefreq: 'daily',
  priority: 0.7,
  exclude: ['/admin/*', '/api/*', '/404'],
  robotsTxtOptions: {
    additionalSitemaps: [
      'https://example.com/server-sitemap.xml',
    ],
  },
}

Add the post-build script to your package.json:

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

Run npm run build and next-sitemap automatically generates your sitemap files in the public/ directory.

Key Features of next-sitemap

Automatic page discovery. It reads your Next.js build output and includes all static and dynamic pages. You do not need to manually list URLs.

Robots.txt generation. Set generateRobotsTxt: true and it creates a robots.txt file alongside your sitemaps, with the sitemap URL included automatically.

Sitemap splitting. The sitemapSize option splits your sitemap into multiple files with an index when you exceed the limit.

Exclusion patterns. Use glob patterns to exclude admin pages, API routes, and other URLs that should not be indexed.

Custom transform. Modify individual URL entries before they are written to the sitemap:

// next-sitemap.config.js
module.exports = {
  siteUrl: 'https://example.com',
  transform: async (config, path) => {
    // Custom priority for blog posts
    if (path.startsWith('/blog/')) {
      return {
        loc: path,
        changefreq: 'weekly',
        priority: 0.8,
        lastmod: new Date().toISOString(),
      }
    }

    // Default
    return {
      loc: path,
      changefreq: config.changefreq,
      priority: config.priority,
      lastmod: new Date().toISOString(),
    }
  },
}

Server-Side Sitemaps for Dynamic Content

For pages that do not exist at build time (like user-generated content or data from an external API), next-sitemap supports server-side sitemap generation:

// app/server-sitemap.xml/route.ts
import { getServerSideSitemap } from 'next-sitemap'
import { db } from '@/lib/database'

export async function GET() {
  const products = await db.product.findMany({
    where: { active: true },
    select: { slug: true, updatedAt: true },
  })

  const fields = products.map((product) => ({
    loc: `https://example.com/products/${product.slug}`,
    lastmod: product.updatedAt.toISOString(),
    changefreq: 'daily' as const,
    priority: 0.7,
  }))

  return getServerSideSitemap(fields)
}

Add this server-side sitemap to the additionalSitemaps array in your config so the sitemap index references it.

Built-In vs. next-sitemap: Which to Use

For most new projects starting with the App Router, the built-in support is enough. Use next-sitemap when you need robots.txt generation, custom transforms, exclusion patterns, or server-side sitemaps for content that does not exist at build time.

If your site has fewer than a few thousand pages and you deploy regularly, the built-in approach is simpler and has no extra dependency. If you have a large or complex site with dynamic content from multiple sources, next-sitemap gives you the flexibility to handle edge cases.

Adding Hreflang for Internationalized Sites

If your Next.js site serves content in multiple languages, your sitemap should include hreflang annotations. These tell search engines which language version to show in each region.

With the built-in approach, add alternates to your sitemap entries:

// app/sitemap.ts
import { MetadataRoute } from 'next'

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://example.com',
      lastModified: new Date(),
      alternates: {
        languages: {
          en: 'https://example.com',
          de: 'https://example.com/de',
          fr: 'https://example.com/fr',
        },
      },
    },
  ]
}

For a deeper look at hreflang in sitemaps, including common pitfalls and validation, see this guide to hreflang in XML sitemaps.

Submitting Your Next.js Sitemap

Once your sitemap is generating correctly, submit it to search engines. The standard location is https://yourdomain.com/sitemap.xml. Both the built-in approach and next-sitemap serve at this URL by default.

Our guide to submitting sitemaps to Google walks through the process using Google Search Console and the ping method.

Verify before submitting

After deploying, visit your sitemap URL in a browser to confirm it renders valid XML. Check that all URLs use your production domain (not localhost), lastmod dates look correct, and no private or draft pages slipped through.

Common Mistakes

Forgetting to revalidate. If you use the built-in approach without ISR, your sitemap only updates on deploy. For sites with frequent content changes, add a revalidate interval.

Including API routes. Next.js API routes (/api/*) should not be in your sitemap. Both approaches can exclude them, but you need to configure it explicitly with next-sitemap.

Stale lastmod dates. Using new Date() for every URL's lastmod defeats the purpose. Pull actual update timestamps from your database. Search engines learn to ignore lastmod values that are always set to the current time.

Not splitting large sitemaps. If your site grows past 50,000 URLs, a single sitemap will be rejected by search engines. Use generateSitemaps or the sitemapSize option to split automatically.

Mixing approaches. Do not use both the built-in app/sitemap.ts and next-sitemap for the same URLs. They will produce conflicting sitemap files. Pick one approach for your static pages and, if needed, use the other only for server-side dynamic sitemaps.

For a broader look at generating sitemaps programmatically across frameworks, see our dynamic sitemaps guide.

References

Validate your Next.js sitemap

Check your generated sitemap for errors, missing pages, and formatting issues.

Try Instant Sitemap