All posts
How-To Guides

Next.js Sitemap Generation: The Complete Guide (App Router)

How to generate a dynamic, always-current sitemap in Next.js 14+ using the App Router — including static routes, dynamic pages, and per-route lastmod dates.

June 12, 2026·6 min read

Next.js App Router has built-in sitemap support that handles the XML formatting for you — you just return an array of URL objects. This guide covers how to set it up, how to handle dynamic routes like blog posts and product pages, and a few gotchas that aren't obvious from the docs.

Basic Setup

Create app/sitemap.ts and export a default function that returns a MetadataRoute.Sitemap array. Next.js serves this at /sitemap.xml automatically, with the correct application/xml content type.

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

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://example.com',
      lastModified: new Date(),
      changeFrequency: 'weekly',
      priority: 1,
    },
    {
      url: 'https://example.com/about',
      lastModified: new Date('2026-05-01'),
      changeFrequency: 'monthly',
      priority: 0.8,
    },
  ];
}

A note on changeFrequency and priority: Google officially ignores both fields. Include them if you want, but don't spend time tuning them — only lastModified is actually used when it's accurate.

Adding Dynamic Routes

For pages with dynamic routes — blog posts, product pages, user profiles — fetch the slugs or IDs from your database or CMS inside the sitemap function. Since the function can be async, you can call any data source directly:

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

async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json() as Promise<{ slug: string; updatedAt: string }[]>;
}

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

  const staticRoutes: MetadataRoute.Sitemap = [
    { url: baseUrl, lastModified: new Date() },
    { url: `${baseUrl}/blog`, lastModified: new Date() },
  ];

  const postRoutes: MetadataRoute.Sitemap = posts.map((post) => ({
    url: `${baseUrl}/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
  }));

  return [...staticRoutes, ...postRoutes];
}

Use the actual updatedAt field from your database for lastModified. Setting it to new Date() (i.e. now) for every post on every request means Google learns to ignore your lastmod — it sees every URL updating constantly even when content hasn't changed.

Setting metadataBase

Next.js needs to know your production domain to generate absolute URLs correctly. Set it in your root layout:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  // ...
};

Without this, Next.js will warn about relative URLs in metadata during the build, and your sitemap may generate incorrect localhost URLs in development.

Splitting Into Multiple Sitemaps

If you have more than 50,000 URLs, you need to split into a sitemap index. Next.js supports this through route-based splitting — create multiple sitemap files at different paths and a sitemap index that references them.

For most Next.js projects, the simplest approach is to use the next-sitemap package, which handles splitting, index generation, and robots.txt generation automatically. Configure it in next-sitemap.config.js and run it as a post-build step.

// next-sitemap.config.js
/** @type {import('next-sitemap').IConfig} */
module.exports = {
  siteUrl: 'https://example.com',
  generateRobotsTxt: true,
  sitemapSize: 5000,
  exclude: ['/dashboard/*', '/auth/*', '/api/*'],
};

Excluding Private Routes

Dashboard pages, auth flows, and API routes should never appear in your sitemap. With the native App Router approach, simply don't include them in the array you return. With next-sitemap, use the exclude array.

For auth and dashboard routes, also add a layout-level noindex so even if they're discovered by following links, they won't be indexed:

// app/dashboard/layout.tsx
export const metadata: Metadata = {
  robots: { index: false, follow: false },
};

Verifying Your Sitemap

After deploying, check:

  1. Open https://yourdomain.com/sitemap.xml in your browser. It should render as valid XML with a list of URLs. If it shows a parse error or returns HTML, check your app/sitemap.ts for syntax errors.
  2. Check that all URLs use your production domain (not localhost). If they do, check your metadataBase setting.
  3. Run a crawl of the sitemap URLs to verify each returns HTTP 200. Any that return 404 or redirect indicate a route configuration issue.
  4. Submit to Google Search Console under Indexing → Sitemaps.

Keeping It Fresh

With the native App Router approach, your sitemap is generated dynamically on request, so it's always current — new blog posts appear immediately without any manual regeneration. If you're using static export or next-sitemap as a post-build step, you'll need to redeploy or run the sitemap generation script whenever you add significant new content to make sure new routes are included.

Try it free

Generate and check your sitemap in minutes

Crawl up to 2,000 URLs for free — no credit card required. See your health score and download your sitemap XML instantly.

Generate your sitemap →