Dynamic URL Endpoints
Introduction
A dynamic sitemap resolves URLs at request time from a live data source, rather than baking them into a static file at build. Use it when your routes come from a CMS, database, or other source that changes between deploys, for example blog posts, product pages, or user-generated content that new pages need to be crawled and indexed for without a rebuild.
If Nuxt prerenders your routes instead (nuxt generate or route rules), the module can extract sitemap data from the rendered HTML. That needs no runtime endpoint. See Nuxt Prerendering. Reach for this guide when the crawler can't discover your URLs from the build output alone.
The module supports two types of data sources:
- JSON responses from API endpoints
- XML sitemaps from external sources
URL Structure Reference
All sitemap URLs follow this structure, whether from JSON endpoints or the urls config:
interface SitemapUrl {
loc: string // Required: The URL path (e.g., '/blog/my-post')
lastmod?: string | Date // Optional: Last modified date (ISO 8601 format or Date object)
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'
priority?: 0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1 // Optional: 0.0 to 1.0
images?: ImageEntry[] // Optional: Array of image objects
videos?: VideoEntry[] // Optional: Array of video objects
news?: GoogleNewsEntry // Optional: Google News entry
_sitemap?: string // Optional: Specify which sitemap this URL belongs to (for multi-sitemap setups)
_encoded?: boolean // Optional: Mark the URL as already encoded
_i18nTransform?: boolean // Optional: Automatically transform the URL for all locales
alternatives?: Array<{ // Optional: For i18n/alternate language URLs
hreflang: string // Language code (e.g., 'en', 'fr', 'es')
href: string // Full URL to alternative version
}>
}Endpoint Response Shape
A dynamic endpoint should return a JSON array of sitemap URL objects:
[
{
"loc": "/blog/hello-world",
"lastmod": "2026-06-29",
"changefreq": "weekly",
"priority": 0.8
},
{
"loc": "/products/sku-123",
"_sitemap": "products"
}
]Use absolute URLs only when the URL belongs to another origin. For your own site, prefer paths like /blog/hello-world; the module resolves them against your configured site.url.
You can verify the endpoint before wiring it into the sitemap:
curl http://localhost:3000/api/__sitemap__/urlsThen check the generated sitemap:
curl http://localhost:3000/sitemap.xmlUsing External XML Sitemaps
If you have an existing XML sitemap, you can reference it directly in your configuration:
export default defineNuxtConfig({
sitemap: {
sources: [
'https://example.com/sitemap.xml',
]
}
})Dynamic URLs from External APIs
When fetching dynamic URLs from external APIs, you have two main approaches:
- Direct source configuration - Use when the API returns data in the correct format
- Custom API endpoint - Use when you need to transform data or implement caching
1. Using Source Configuration
For APIs that require authentication or custom headers, provide sources as an array with fetch options:
export default defineNuxtConfig({
sitemap: {
sources: [
// Unauthenticated endpoint
'https://api.example.com/pages/urls',
// Authenticated endpoint
[
'https://authenticated-api.example.com/pages/urls',
{ headers: { Authorization: 'Bearer <token>' } }
]
]
}
})2. Creating Custom Endpoints
Which handler should I use?
Use defineSitemapEventHandler() from #sitemap/server. It wraps defineEventHandler() from nuxt/server. It types the return value as SitemapUrlInput[] so your editor checks each URL object. It adds no caching, because the module handles that for you:
- The resolved sitemap is cached in production for
cacheMaxAgeSeconds, 10 minutes by default. This covers source fetching, normalization and sorting. - All chunks of a sitemap share one cache entry, so chunking does not multiply requests to your endpoint.
The module cache is disabled in development and during prerendering, so every request reaches your endpoint there.
Reach for Nitro's defineCachedEventHandler() when your endpoint is expensive enough that one call per cache window still hurts, for example a slow, rate limited, or metered upstream API. Each named sitemap resolves its own sources, so this also helps when several sitemaps read the same endpoint. It has no sitemap-aware types, so annotate the return value:
import type { SitemapUrlInput } from '#sitemap/types'
export default defineCachedEventHandler(async (): Promise<SitemapUrlInput[]> => {
const posts = await $fetch<{ slug: string, modified: string }[]>('https://slow-api.example.com/posts')
return posts.map(post => ({
loc: `/blog/${post.slug}`,
lastmod: post.modified,
}))
}, {
maxAge: 60 * 60, // 1 hour
name: 'sitemap-posts',
})maxAge higher than cacheMaxAgeSeconds. A lower value has little effect, because the module cache already absorbs most requests.Step 1: Create the API endpoint
Use the defineSitemapEventHandler() helper to create type-safe sitemap endpoints:
import type { SitemapUrlInput } from '#sitemap/types'
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#sitemap/server'
export default defineSitemapEventHandler(() => {
return [
{
loc: '/about-us',
// Specify which sitemap this URL belongs to
_sitemap: 'pages',
},
] satisfies SitemapUrlInput[]
})import type { SitemapUrl } from '#sitemap/types'
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#sitemap/server'
export default defineSitemapEventHandler(async () => {
const [posts, pages] = await Promise.all([
$fetch<{ path: string, slug: string }[]>('https://api.example.com/posts')
.then(posts => posts.map(p => ({
loc: `/blog/${p.slug}`, // Transform to your domain structure
_sitemap: 'posts',
} satisfies SitemapUrl))),
$fetch<{ path: string }[]>('https://api.example.com/pages')
.then(pages => pages.map(p => ({
loc: p.path,
_sitemap: 'pages',
} satisfies SitemapUrl))),
])
return [...posts, ...pages]
})// server/api/__sitemap__/wordpress.ts
import { defineSitemapEventHandler } from '#sitemap/server'
export default defineSitemapEventHandler(async () => {
const posts = await $fetch('https://api.externalwebsite.com/wp-json/wp/v2/posts')
return posts.map(post => ({
// Transform external URL to your domain
loc: `/blog/${post.slug}`, // NOT post.link
lastmod: post.modified,
changefreq: 'weekly',
priority: 0.7,
}))
})import type { SitemapUrl } from '#sitemap/types'
// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#sitemap/server'
export default defineSitemapEventHandler(async () => {
const config = useRuntimeConfig()
const baseUrl = config.public.siteUrl
const locales = config.public.i18n.locales.map(locale => locale.code)
const isoLocales = Object.fromEntries(
config.public.i18n.locales.map(locale => ([locale.code, locale.iso]))
)
// Example: Fetch data for each locale
const apiQueries = locales.map(locale =>
$fetch(`${config.public.apiEndpoint}/sitemap/${locale}/products`)
)
const sitemaps = await Promise.all(apiQueries)
return sitemaps.flat().map(entry => ({
// explicit sitemap mapping
_sitemap: isoLocales[entry.locale],
loc: `${baseUrl}/${entry.locale}/product/${entry.url}`,
alternatives: entry.alternates?.map(alt => ({
hreflang: isoLocales[alt.locale],
href: `${baseUrl}/${alt.locale}/product/${alt.url}`
}))
} satisfies SitemapUrl))
})Step 2: Configure the endpoint
Add your custom endpoint to the sitemap configuration:
export default defineNuxtConfig({
sitemap: {
sources: [
'/api/__sitemap__/urls',
]
}
})export default defineNuxtConfig({
sitemap: {
sitemaps: {
posts: {
sources: [
'/api/__sitemap__/urls/posts',
]
},
pages: {
sources: [
'/api/__sitemap__/urls/pages',
]
}
}
}
})Handling Pre-Encoded URLs
By default, the module automatically encodes URL paths. This handles special characters like spaces and unicode (e.g., emojis, accented characters).
If your API or CMS returns URLs that are already encoded, mark them with _encoded: true to prevent double-encoding.
import { defineSitemapEventHandler } from '#sitemap/server'
export default defineSitemapEventHandler(async () => {
// URLs from your API are already encoded
const urls = await $fetch<{ path: string }[]>('https://api.example.com/pages')
// e.g. [{ path: '/products/%24pecial-offer' }, { path: '/blog/%F0%9F%98%85' }]
return urls.map(url => ({
loc: url.path,
_encoded: true,
}))
})_encoded: true is set, the module skips automatic encoding entirely. Make sure your URLs are properly encoded.