Learn Pro Sitemap in Next.js 15: Built-in sitemap.ts vs Custom XML

Next.js 15 Sitemap Showdown: Built-in sitemap.(ts|js)
vs Custom sitemap.xml
(and when each wins)
If you’re building a content-heavy site in Next.js 15 and you care about technical SEO, your sitemap strategy matters. And yes—you can do it two ways:
The built-in App Router sitemap using
app/sitemap.ts
(typed, ergonomic, “just works”).A custom XML sitemap via a Route Handler (total control, any extension/format you want).
This guide compares both approaches in plain English, highlights dynamic ISR options, and gives you production-ready templates from small to large. Let’s make your crawl budget and indexation smile.
TL;DR (what you’ll learn)
How the Next.js metadata route for sitemaps works, including supported fields like
lastModified
,changeFrequency
,priority
,images
,videos
, and localizedalternates
.How to split big sitemaps with
generateSitemaps()
and keep under 50,000 URLs / 50MB per file.Why Google ignores
<priority>
and<changefreq>
, and what to focus on instead (<lastmod>
, canonical URLs).How to implement ISR for sitemaps (e.g.,
export const revalidate = 3600
) in Route Handlers.How to advertise your sitemap from robots.txt using Next’s metadata file.
Built-in Next.js sitemap: what you get
Next.js ships a metadata file convention: drop app/sitemap.ts
(or .js
) in your App Router and export a function that returns an array of URL entries. Next serializes this to /sitemap.xml
automatically. You can also include images, videos, and localized alternates (hreflang). It’s simple, typed, and reduces boilerplate.
Pros
Fast & ergonomic: One file, typed via
MetadataRoute.Sitemap
.Less brittle: Next handles XML serialization and namespaces for images/videos/alternates.
Scales: Use
generateSitemaps()
to split into many sitemaps and serve a sitemap index automatically via dynamic routes.Works with robots: Pair with
app/robots.ts
and point to your sitemap.
Cons
Not all extensions: If you need exotic or niche XML extensions (e.g., Google News tags not modeled by the built-in types), you’ll likely switch to a custom XML route.
Output format is opinionated: You follow the conventions Next supports; full custom markup requires a hand-rolled XML response.
Hand-rolled sitemap.xml
: when you want absolute control
Using a Route Handler (e.g., app/sitemap.xml/route.ts
) you can return raw XML, add any namespaces, stream huge outputs, tweak headers, or build a sitemap index by hand. You’re responsible for the XML correctness and keeping within protocol limits (50k URLs or 50MB per file).
Pros
- Unlimited flexibility: Any XML element/extension you need.
- Streaming/advanced perf: Build on top of Web
Response
/streams for massive catalogs. - Exact control: Custom indexes across domains, special groupings, etc.
Cons
More code, more surface for mistakes: Encoding, namespaces, and limits are on you.
You must maintain it: Schema changes or mistakes can break the feed.
Reinventing what Next already gives for common sitemap needs.
Important SEO truths (so you don’t fight the wrong battles)
Google ignores
<priority>
and<changefreq>
. Don’t stress about “0.7 vs 0.8”; focus on accurate<lastmod>
and correct canonical URLs.Keep within limits: Max 50,000 URLs per sitemap or 50MB uncompressed—then split and use a sitemap index.
Robots + sitemaps: Put a
Sitemap: https://your-site.com/sitemap.xml
line in robots.txt (or multiple lines if you split). You can generate robots with Next.
Code templates (from small to big)
Note: Examples use TypeScript with the App Router.
1) Small: a minimal built-in sitemap
// app/sitemap.ts
import type { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = 'https://example.com'
return [
{
url: `${base}/`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${base}/about`,
lastModified: new Date('2025-08-01'),
changeFrequency: 'monthly',
priority: 0.5,
},
]
}
This yields a valid /sitemap.xml
with <urlset>
, <loc>
, <lastmod>
, <changefreq>
, and <priority>
. (Remember Google ignores the last two; include them only if you want parity with other engines.)
2) Small-plus: add images, videos, and localized alternates
// app/sitemap.ts
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const base = 'https://example.com'
return [
{
url: `${base}/blog/my-post`,
lastModified: new Date('2025-08-20'),
images: [`${base}/og/my-post.jpg`],
videos: [{ title: 'Teaser', thumbnail_loc: `${base}/thumbs/my-post.webp`, description: 'A short teaser' }],
alternates: {
languages: {
es: `${base}/es/blog/my-post`,
de: `${base}/de/blog/my-post`,
},
},
},
]
}
Built-in support for images, videos, and localized alternates
(hreflang) keeps the code tidy and schema-correct.
3) Medium: Dynamic ISR sitemap (fetch DB/API + cache)
// app/sitemap.ts
import type { MetadataRoute } from 'next'
// Re-generate this file every 10 minutes (ISR for route handlers)
export const revalidate = 600
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const base = 'https://example.com'
// Replace with your DB / CMS call; fetch() is cached per revalidate above.
const res = await fetch('https://api.example.com/posts?published=true', {
// If you need per-request invalidation, pair with revalidateTag() or revalidatePath().
// Otherwise, this respects export const revalidate = 600.
next: { revalidate: 600 },
})
const posts: Array<{ slug: string; updatedAt: string }> = await res.json()
return posts.map((p) => ({
url: `${base}/blog/${p.slug}`,
lastModified: new Date(p.updatedAt),
}))
}
export const revalidate
on Route Handlers makes your sitemap semi-static: it’s generated, cached, and revalidated on the interval you set—perfect for “mostly static” catalogs that change periodically.
4) Big: split huge catalogs with generateSitemaps()
// app/products/sitemap.ts
import type { MetadataRoute } from 'next'
export async function generateSitemaps() {
// Imagine you have ~180k products -> 4 parts (50k each, last part partial)
return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }]
}
export default async function sitemap({ id }: { id: number }): Promise<MetadataRoute.Sitemap> {
const base = 'https://example.com'
const start = id * 50000
const end = start + 50000
// Pull only the slice of IDs you need for this part
const res = await fetch(`https://api.example.com/products?start=${start}&end=${end}`, { next: { revalidate: 3600 } })
const products: Array<{ id: string; updatedAt: string }> = await res.json()
return products.map((p) => ({
url: `${base}/product/${p.id}`,
lastModified: new Date(p.updatedAt),
}))
}
This yields /products/sitemap/0.xml
, /1.xml
, etc., keeping you compliant with the 50k URL rule and making Search Console diagnostics easier per segment.
5) Robots that advertises your sitemap
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/' },
sitemap: 'https://example.com/sitemap.xml',
}
}
Let crawlers discover your sitemap reliably; Next generates robots.txt
from this.
Custom XML route: total control (advanced)
If you want a hand-crafted XML (e.g., Google News tags, cross-domain indices, or custom streaming), use a Route Handler:
// app/sitemap.xml/route.ts
export const revalidate = 900 // re-generate every 15 minutes
function xmlEscape(s: string) {
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
}
export async function GET() {
const base = 'https://example.com'
const res = await fetch('https://api.example.com/urls', { cache: 'no-store' })
const urls: Array<{ loc: string; lastmod?: string }> = await res.json()
const head =
`<?xml version="1.0" encoding="UTF-8"?>` +
`<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`
const body = urls
.map(
(u) =>
`<url><loc>${xmlEscape(`${base}${u.loc}`)}</loc>` +
(u.lastmod ? `<lastmod>${u.lastmod}</lastmod>` : '') +
`</url>`
)
.join('')
const xml = `${head}${body}</urlset>`
return new Response(xml, {
headers: { 'Content-Type': 'application/xml; charset=UTF-8' },
})
}
Because Route Handlers use the Web Request/Response APIs, you can also stream (for very large feeds) and set cache headers directly. Pair with export const revalidate
for ISR-style regeneration.
Tip: If you need to split by hand, create
app/sitemaps/[part]/route.ts
and return an index atapp/sitemap_index.xml/route.ts
. Keep each part under the protocol limits and link them from the index.
Which one should you choose?
Choose the built-in Next.js sitemap when…
You want simple, robust, and typed generation with minimal code.
You need images, videos, or localized alternates without fiddling with XML.
You’re okay with Next’s conventions and don’t need rare extensions.
Choose a custom XML route when…
You require specialized sitemap extensions not modeled by Next’s type (e.g., news-specific tags), cross-domain gymnastics, or special headers.
You want to stream output, build bespoke indexes, or mirror an existing enterprise feed format.
In practice, most product, blog, docs, and marketing sites are best served by the built-in path. It’s fast to ship, hard to break, and plays nicely with the App Router.
Practical SEO pointers (beyond the code)
Prioritize accuracy of
<lastmod>
: keep it in sync with real content updates; Google uses it when it’s consistently accurate.Canonicalize URLs: only include the canonical for each piece of content. Duplicates waste crawl budget.
Group logically if you split**:** e.g.,
products
,blog
,docs
. Easier monitoring in Search Console.Advertise via robots and submit in Search Console; both improve discoverability and give you error reporting.
A simple decision framework
Do you need custom XML features?
No → Use
app/sitemap.ts
(plusgenerateSitemaps()
if needed).Yes → Implement a Route Handler at
app/sitemap.xml/route.ts
.
Is your catalog massive?
- Yes → Split with
generateSitemaps()
or manual index; keep files under the protocol limits.
- Yes → Split with
Do URLs change frequently but not constantly?
- Yes → Add ISR to your sitemap route with
export const revalidate = <seconds>
.
- Yes → Add ISR to your sitemap route with
Final take
For most Next.js 15 apps, the built-in sitemap is the sweet spot: compact, strongly typed, and feature-rich (images, videos, hreflang), with seamless scaling through generateSitemaps()
and easy robots integration. When you outgrow its boundaries—think news, multi-tenant cross-domain indices, or streaming—a custom XML Route Handler gives you unlimited flexibility while still enjoying Next’s caching and ISR ergonomics.
Whichever route you pick, keep your sitemap accurate, split correctly, and discoverable. That’s how you win the boring but crucial parts of technical SEO—and that’s how you help search engines find, understand, and rank the great content you’re shipping.