A file-based blog can still have a dynamic URL problem. Nuxt knows that pages/blog/[slug].vue exists, but it cannot infer every real slug stored in Nuxt Content. If those article URLs are missing from sitemap.xml, search engines receive an incomplete map of the site.
The reliable solution is to query the content collection on the server, transform every document into a sitemap entry, and register that endpoint as a source for @nuxtjs/sitemap.
This guide uses Nuxt Content v3 and the current Nuxt Sitemap module. It works for SSR projects and can also be used in a statically generated Nuxt site.
The Short Answer
Create a sitemap source at server/api/__sitemap__/urls.ts:
import { queryCollection } from '#imports'
export default defineSitemapEventHandler(async (event) => {
const posts = await queryCollection(event, 'blog')
.select('path', 'date', 'updated')
.all()
return posts.map(post => ({
loc: post.path,
lastmod: post.updated || post.date,
}))
})
Then register the source in nuxt.config.ts:
export default defineNuxtConfig({
modules: [
'@nuxtjs/sitemap',
'@nuxt/content',
],
site: {
url: 'https://example.com',
},
sitemap: {
sources: ['/api/__sitemap__/urls'],
},
})
After starting or generating the site, open /sitemap.xml and confirm that it contains the actual article URLs rather than the placeholder route /blog/[slug].
Why Nuxt Dynamic Routes Need Extra Sitemap Data
Nuxt can discover static routes such as /about from the pages directory. A parameterized route is different:
pages/
└── blog/
└── [slug].vue
The file describes a route pattern, not the list of valid URLs. The actual slugs may come from Markdown files, a CMS, or an API:
content/
└── blog/
├── nuxt-seo-guide.md
└── fix-canonical-urls.md
Nuxt Content generates paths for page collections, so these documents might become:
/blog/nuxt-seo-guide
/blog/fix-canonical-urls
The sitemap module needs those resolved paths. A server-side collection query provides them without maintaining a second manual list.
Step 1: Define a Typed Blog Collection
A page collection gives each document a generated path. Adding a schema also makes fields such as date, updated, and draft easy to query safely.
// content.config.ts
import { defineCollection, defineContentConfig, z } from '@nuxt/content'
export default defineContentConfig({
collections: {
blog: defineCollection({
type: 'page',
source: 'blog/**/*.md',
schema: z.object({
date: z.string(),
updated: z.string().optional(),
draft: z.boolean().default(false),
category: z.string().optional(),
tags: z.array(z.string()).default([]),
}),
}),
},
})
A matching Markdown file can use this frontmatter:
---
title: "How to Fix a Nuxt SEO Problem"
description: "A concise summary for readers and search results."
date: "2026-07-18"
updated: "2026-07-18"
draft: false
category: "Nuxt"
tags: ["Nuxt", "SEO"]
---
Use an ISO 8601 date such as YYYY-MM-DD. More precise timestamps are also valid when the time is meaningful.
Step 2: Create the Dynamic Sitemap Endpoint
The server version of queryCollection receives the request event as its first argument. Filter drafts, select only the fields required by the sitemap, and return the module's expected URL objects.
// server/api/__sitemap__/urls.ts
import { queryCollection } from '#imports'
const normalizePath = (path: string) => {
if (path === '/') return '/'
return `${path.replace(/\/+$/, '')}/`
}
export default defineSitemapEventHandler(async (event) => {
const posts = await queryCollection(event, 'blog')
.where('draft', '=', false)
.select('path', 'date', 'updated')
.all()
return posts.map(post => ({
loc: normalizePath(post.path),
lastmod: post.updated || post.date,
}))
})
The normalization function is optional. Use it when the public site has a trailing-slash convention and make sure canonical URLs follow the same convention.
Do not set lastmod to the current time on every build. That tells crawlers every page changed even when the article did not. Use the real publication or modification date instead.
Step 3: Add Static and Taxonomy URLs
The endpoint can combine content URLs with routes for categories, tags, and important static pages. A Set prevents duplicate taxonomy entries.
// server/api/__sitemap__/urls.ts
import { queryCollection } from '#imports'
const slugify = (value: string) => value
.trim()
.toLowerCase()
.replace(/&/g, 'and')
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
export default defineSitemapEventHandler(async (event) => {
const posts = await queryCollection(event, 'blog')
.where('draft', '=', false)
.select('path', 'date', 'updated', 'category', 'tags')
.all()
const categories = new Set<string>()
const tags = new Set<string>()
const articleUrls = posts.map((post) => {
if (post.category) categories.add(post.category)
post.tags.forEach(tag => tags.add(tag))
return {
loc: post.path,
lastmod: post.updated || post.date,
}
})
return [
{ loc: '/' },
{ loc: '/blog/' },
{ loc: '/about/' },
...articleUrls,
...Array.from(categories).map(category => ({
loc: `/categories/${slugify(category)}/`,
})),
...Array.from(tags).map(tag => ({
loc: `/tags/${slugify(tag)}/`,
})),
]
})
Only include indexable URLs that return a successful response. Login pages, internal search results, preview routes, draft content, and redirects generally do not belong in the sitemap.
Step 4: Register the Source
Add the endpoint to the sitemap configuration:
// nuxt.config.ts
export default defineNuxtConfig({
modules: [
'@nuxtjs/sitemap',
'@nuxt/content',
],
site: {
url: 'https://example.com',
trailingSlash: true,
},
sitemap: {
sources: ['/api/__sitemap__/urls'],
},
})
The site.url value is important because it lets the module turn relative paths into absolute URLs for the deployed domain.
Handling Frontmatter Stored Under meta
If a collection does not define a custom schema, extra frontmatter fields may be available under the document's meta object instead of as top-level properties.
Use this variation:
import { queryCollection } from '#imports'
type PostMeta = {
date?: string
updated?: string
draft?: boolean
}
export default defineSitemapEventHandler(async (event) => {
const posts = await queryCollection(event, 'blog')
.select('path', 'meta')
.all()
return posts.flatMap((post) => {
const meta = post.meta as PostMeta
if (meta.draft) return []
return [{
loc: post.path,
lastmod: meta.updated || meta.date,
}]
})
})
Inspect one query result if you are unsure where a field is stored. A common cause of an empty or undated sitemap is selecting date when the actual value is meta.date.
Fixing Locale Prefixes
Content paths and public URLs are not always identical in multilingual projects. For example, a file may produce /en/blog/example, while the default locale is publicly served at /blog/example.
Transform the path before returning it:
const toPublicPath = (path: string) => {
return path.replace(/^\/en\//, '/')
}
For non-default locales, return the correct localized path and add alternate language URLs when appropriate. Never publish an internal content path that redirects to a different public URL.
Sitemap Discovery Is Not the Same as Prerendering
A sitemap helps crawlers discover URLs. It does not automatically guarantee that every dynamic page is emitted as HTML during static generation.
For an SSG deployment, make sure at least one of these is true:
- The blog index links to every article and Nitro's prerender crawler can follow those links.
- Dynamic routes are added to
nitro.prerender.routes. - Your build setup uses a content-aware integration that adds the routes.
After running nuxt generate, inspect the generated output for both the article HTML files and sitemap.xml.
Validation Checklist
Before deploying, check the following:
- Open
/api/__sitemap__/urlsand verify that it returns a JSON array. - Open
/sitemap.xmland search for a recent article slug. - Confirm that every
<loc>uses the production domain and preferred slash format. - Test several sitemap URLs and make sure they return
200, not301,404, or500. - Confirm that drafts and private pages are absent.
- Verify that
lastmodchanges only when the corresponding content changes. - Submit the production sitemap URL in Google Search Console.
Common Problems
The Sitemap Contains No Blog Posts
Check the collection name, source glob, and query fields. queryCollection(event, 'blog') must use the exact key defined in content.config.ts.
Paths Include an Unwanted Locale
Map internal content paths to their public route equivalents before returning them from the endpoint.
The Endpoint Works in Development but Not After Static Generation
Confirm that the sitemap is generated during the build and that the output contains sitemap.xml. A runtime-only server endpoint cannot execute later on a purely static host.
Canonical URLs and Sitemap URLs Disagree
Choose one URL format and apply it everywhere. Redirects, canonical tags, internal links, and sitemap entries should point to the same preferred URL. A sitemap is a canonicalization signal, but it should not be used to compensate for conflicting tags.
Every Build Changes lastmod
Do not use new Date() as the default for unchanged content. Store an actual updated value in frontmatter or fall back to the publication date.
Frequently Asked Questions
Does Nuxt automatically add Nuxt Content pages to the sitemap?
Static pages can be discovered automatically, but dynamic slug routes may require a content integration or a custom URL source. Always inspect the generated XML instead of assuming every document is included.
Should a sitemap include categories and tags?
Include them when those pages are useful, indexable landing pages with unique content. Exclude empty, duplicate, or thin taxonomy pages.
Do priority and changefreq improve rankings?
They do not replace crawlable architecture, accurate canonical URLs, or useful content. Focus first on complete URL coverage and truthful modification dates.
Can I use the same approach with an external CMS?
Yes. Replace the collection query with an API request and map the CMS records to objects containing at least loc, with an optional lastmod.
Next Steps
A complete sitemap is one part of technical SEO. Each article should also have a unique title, description, canonical URL, crawlable internal links, and relevant structured data. For the structured-data layer, see the JSON-LD guide for SEO.
Official references: