
Next.js sitemap.xml
A sitemap is how you hand search engines a structured, authoritative list of every URL on your site worth crawling — rather than relying entirely on link-following discovery, which can miss pages that aren't well-linked internally. Next.js supports the Sitemaps XML format as a first-class file convention, with the same static-file-or-generated-code duality as its other metadata conventions, plus a specific mechanism for splitting a sitemap across multiple files once a site grows past what one file can reasonably hold.
Static Sitemap
For smaller sites, a literal sitemap.xml at the root of app works exactly as you'd expect from any standard sitemap:
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://acme.com</loc>
<lastmod>2023-04-06T15:02:24.021Z</lastmod>
<changefreq>yearly</changefreq>
<priority>1</priority>
</url>
<url>
<loc>https://acme.com/about</loc>
<lastmod>2023-04-06T15:02:24.021Z</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
Generating a Sitemap with Code
sitemap.js or sitemap.ts can default-export a function returning an array of URL entries, which Next.js serializes into the same XML shape automatically:
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://acme.com",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
url: "https://acme.com/about",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.8,
},
{
url: "https://acme.com/blog",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.5,
},
];
}
This is where a sitemap genuinely earns the "generate it with code" approach over a hand-maintained static file: a blog with hundreds of posts, a product catalog with thousands of SKUs — anything where the URL list changes as often as your content does — should build this array from your actual data source (a database query, a CMS API call) rather than someone remembering to manually update a static XML file every time a page is published. Like the other metadata-file conventions, sitemap.js is a special Route Handler, cached by default unless it reaches for a Request-time API or dynamic config.
Image and Video Sitemaps
Beyond plain URLs, individual entries can carry images and videos arrays, which Google's crawlers use specifically for image and video search indexing — a meaningfully different (and often overlooked) discovery surface from ordinary web search:
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://example.com",
lastModified: "2021-01-01",
changeFrequency: "weekly",
priority: 0.5,
images: ["https://example.com/image.jpg"],
},
];
}
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://example.com",
lastModified: "2021-01-01",
changeFrequency: "weekly",
priority: 0.5,
videos: [
{
title: "example",
thumbnail_loc: "https://example.com/image.jpg",
description: "this is the description",
},
],
},
];
}
If a meaningful share of your traffic potential comes from image or video search rather than standard web search — a recipe site, a video tutorial platform — populating these fields is a genuinely underused lever most teams never get around to, precisely because it's easy to forget these fields exist at all when just listing plain URLs already satisfies the basic sitemap requirement.
Localized Sitemaps
For internationalized sites, alternates.languages lets a single URL entry declare its translated counterparts, generating the xhtml:link hreflang tags search engines use to serve the right language variant to the right audience:
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://acme.com",
lastModified: new Date(),
alternates: {
languages: {
es: "https://acme.com/es",
de: "https://acme.com/de",
},
},
},
];
}
This produces <xhtml:link rel="alternate" hreflang="es" .../> and its German counterpart alongside the primary URL entry — a single, coherent way to declare a page's full set of language variants directly in your sitemap, rather than relying solely on hreflang tags scattered across individual page <head> elements.
Splitting a Sitemap Across Multiple Files
A single sitemap file works fine until a site's URL count approaches search engines' per-file limits — Google specifically caps sitemaps at 50,000 URLs each. There are two supported ways to split beyond that:
Nesting sitemap.(xml|js|ts) in multiple route segments — app/sitemap.xml alongside app/products/sitemap.xml, each covering its own section.
Using generateSitemaps for programmatic splitting within a single route, which is the better fit when the split needs to be data-driven rather than structurally tied to your route hierarchy:
import type { MetadataRoute } from "next";
import { BASE_URL } from "@/app/lib/constants";
export async function generateSitemaps() {
// Fetch the total number of products and calculate the number of sitemaps needed
return [{ id: 0 }, { id: 1 }, { id: 2 }, { id: 3 }];
}
export default async function sitemap(props: {
id: Promise<string>;
}): Promise<MetadataRoute.Sitemap> {
const id = await props.id;
// Google's limit is 50,000 URLs per sitemap
const start = id * 50000;
const end = start + 50000;
const products = await getProducts(
`SELECT id, date FROM products WHERE id BETWEEN ${start} AND ${end}`,
);
return products.map((product) => ({
url: `${BASE_URL}/product/${product.id}`,
lastModified: product.date,
}));
}
generateSitemaps returns an array of { id } objects up front — effectively declaring how many sitemap "chunks" exist — and the sitemap function is then invoked once per id, receiving it as a promise, to generate that specific chunk's content. The resulting files are served at /.../sitemap/[id].xml — in this example, /product/sitemap/1.xml and so on, one file per generated id.
The Full Return Shape
type Sitemap = Array<{
url: string;
lastModified?: string | Date;
changeFrequency?:
"always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
priority?: number;
alternates?: { languages?: Languages<string> };
images?: string[];
videos?: Videos[];
}>;
Every field beyond url is optional, but lastModified and changeFrequency in particular are worth setting deliberately rather than omitting — they're the fields search engines actually use to decide how aggressively to re-crawl a given URL, and an accurate changeFrequency can meaningfully affect how quickly updated content gets re-indexed.
Version History
| Version | Changes |
|---|---|
v16.0.0 | id (in generateSitemaps-driven sitemaps) became a promise resolving to a string |
v14.2.0 | Localization support added |
v13.4.14 | changeFrequency and priority attributes added |
v13.3.0 | sitemap introduced |
Key Takeaways
| Aspect | Detail |
|---|---|
| Static form | app/sitemap.xml, hand-written and served as-is |
| Dynamic form | app/sitemap.ts/.js, default-exporting a function returning an array of URL entries |
| Per-sitemap URL limit | 50,000 (Google's limit) — split beyond that via nested segments or generateSitemaps |
| Image/video sitemaps | The images/videos fields feed a separate, often-overlooked search discovery surface |
| Localization | alternates.languages generates hreflang link tags per URL entry |
| Caching | Cached by default, like every other metadata-file Route Handler |
For most sites, a hand-maintained static sitemap.xml covers the need adequately. The moment your URL count is driven by content rather than fixed routes — blog posts, products, any growing dataset — generating it from your actual data source with sitemap.ts (and generateSitemaps once you cross the 50,000-URL ceiling) is the pattern that scales without anyone having to remember to update a static file by hand.


