SEO and metadata
This guide covers the SEO setup included in the boilerplate: the sitemap, robots.txt, page titles, and how to add metadata to your pages.
Overview
Three modules do the work, and all of them read the same site config:
@nuxtjs/sitemap—/sitemap.xml@nuxtjs/robots—/robots.txt, andDisallow: /for non-production deploysnuxt-seo-utils— title template,rel=canonical, and the OpenGraph and Twitter defaults
export default defineNuxtConfig({
site: {
url: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000',
name: process.env.NUXT_PUBLIC_SITE_NAME,
},
})
Set those two environment variables and the rest follows: titles carry your name,
canonicals and robots.txt point at your host.
Sitemap
The sitemap is generated at /sitemap.xml and covers your public pages, docs and
blog posts.
Configuration
Minimal configuration in nuxt.config.ts:
export default defineNuxtConfig({
site: {
url: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000',
},
sitemap: {
exclude: ['/auth/**', '/app/**', '/checkout/**'],
sources: ['/api/__sitemap__/urls'],
},
})
URLs reach the sitemap by three routes:
- Static pages are discovered from
app/pages/by the module itself. - Docs come from the
docscontent collection, which opts in via its schema. - Blog posts come from
server/api/__sitemap__/urls.ts, registered above as a custom source.
Docs: opting a collection in
A collection joins the sitemap by declaring the sitemap key in its schema with
defineSitemapSchema():
import { defineSitemapSchema } from '@nuxtjs/sitemap/content'
export default defineContentConfig({
collections: {
docs: defineCollection({
type: 'page',
source: {
include: 'docs/**/*.{md,yml}',
exclude: ['**/_dir.yml'],
},
schema: z.object({
sitemap: defineSitemapSchema(),
}),
}),
},
})
That schema key is also what makes sitemap: false in a page's frontmatter work as
an opt-out.
Give each path exactly one collection. A collection with a catch-all
source: '**/*.md' claims files the other collections own, and then advertises URLs
from a collection that does not serve the route.
Blog posts: the sitemap follows the live source
The boilerplate can serve blog posts from markdown or from the database, selected by
NUXT_PUBLIC_BLOG_SOURCE. The posts collection is therefore deliberately not a
sitemap collection — when the source is db, its markdown files are not served at
all, and listing them would advertise URLs the site does not have.
server/api/__sitemap__/urls.ts branches on the same variable that
app/pages/blog/index.vue branches on, so the sitemap and the rendered blog cannot
disagree:
export default defineSitemapEventHandler(async event => {
const config = useRuntimeConfig(event)
if (config.public.blogSource === 'db') {
const { listPostPaths } = await import('@@/server/services/posts-server-service')
const posts = await listPostPaths('blog')
return posts.map(post => ({ loc: post.path, lastmod: post.updatedAt }))
}
// `draft` is stored as 0/1 by the content SQLite backend.
const posts = await queryCollection(event, 'posts')
.where('draft', '=', 0)
.where('path', 'LIKE', '/blog/%')
.select('path', 'date', 'meta')
.all()
return posts
// `sitemap: false` is not in the collection schema, so it lands in `meta`.
.filter(post => post.meta.sitemap !== false)
.map(post => ({ loc: post.path, lastmod: post.date }))
})
Changelog entries are excluded from both branches: the API serves them, but there is
no /changelog/<slug> route to send a crawler to.
Vercel deployment
For Vercel and other serverless platforms, configure Nuxt Content to use the native SQLite connector:
export default defineNuxtConfig({
content: {
experimental: {
sqliteConnector: 'native', // Required for Vercel
},
},
})
This avoids issues with the better-sqlite3 native module in serverless
environments. The native connector needs Node.js 22.5.0 or newer.
Robots.txt
@nuxtjs/robots serves /robots.txt. Declare the blocked routes in
nuxt.config.ts and the Sitemap: line is filled in from site.url:
export default defineNuxtConfig({
robots: {
disallow: ['/api/', '/auth/', '/app/', '/checkout/'],
header: false,
metaTag: false,
},
})
Add bot-specific rules with groups; the
module documentation
covers the full option set.
In development the module blocks indexing outright, so /robots.txt serves
Disallow: /. Append ?mockProductionEnv to see what production will serve.
Per-page directives are off
header and metaTag are disabled, so pages carry no X-Robots-Tag and no
<meta name="robots">: /robots.txt is the only place the app states an
indexing policy. That leaves the per-page directive to whatever sits in front of
it — a CDN rule, a reverse proxy, a WAF. Turn either back on to let the app own
it instead, but own it in one place. A page saying index while the edge says
noindex is a contradiction search engines resolve by guessing.
Keeping a deploy out of the index
Set NUXT_SITE_ENV=staging on any deploy that should not be indexed — a preview
build, a staging host, a demo. The module then serves Disallow: / and
@nuxtjs/sitemap stops advertising URLs. Leave it unset in production and
neither appears.
This is an environment variable rather than a code change, so the same build artifact can be indexable on one host and not on another.
Disallow: / stops the crawl, which is a stronger statement than it sounds:
a page that is never fetched is a page whose noindex is never read, so a URL
already in the index can stay there as a bare link. When the goal is removing
pages from results rather than sparing the server, serve noindex and leave the
crawl open from whatever owns the directive — the rule at your edge, or the app
itself with metaTag: true — and do not set NUXT_SITE_ENV=staging as well.
Page titles
nuxt-seo-utils builds every title as %s %separator %siteName, so a page sets
only its own half:
<script setup lang="ts">
useSeoMeta({ title: 'Pricing' }) // renders "Pricing - Your Site Name"
</script>
%siteName is site.name (NUXT_PUBLIC_SITE_NAME), so a rebranded copy suffixes
its own name with no page edits. The separator is set once:
export default defineNuxtConfig({
app: {
head: {
templateParams: { separator: '-' },
},
},
})
Leave it unset and titles render Pricing | Your Site Name.
A page whose title already carries the brand opts out of the suffix:
<script setup lang="ts">
useSeoMeta({ titleTemplate: '%s', title: 'Acme — the fastest way to ship' })
</script>
Keep the brand out of translated strings — the template already appends it, in every language.
NUXT_PUBLIC_SITE_NAME is required: server/plugins/validate-env.ts stops the
server in production without it. Leave it unset in development and titles render
the token itself, Pricing - %siteName, rather than dropping the suffix.
The default share image
app/app.vue sets og:image and twitter:image to public/og-image.jpg for
every page that does not supply its own, and its alt text to site.name.
Replace that file. The one shipped with the boilerplate is a Rapidstack card, and it is what every link to your site unfurls to on X, Slack, LinkedIn and iMessage until you swap it. Keep the name and the 1200x630 dimensions and nothing else needs to change.
Page metadata
Beyond the title, nuxt-seo-utils sets rel=canonical, og:url, og:type,
og:site_name, og:locale and <html lang> on every page from your site config
and the active locale, and infers og:title and og:description from the page's
title and description. Pages add only what is theirs:
<script setup lang="ts">
useSeoMeta({
title: 'About us',
description: 'Learn more about our company and mission',
})
</script>
<template>
<div>
<h1>About us</h1>
<p>Content here...</p>
</div>
</template>
A page with its own share image adds that, and nothing else — og:title,
og:description and twitter:card are all inferred:
<script setup lang="ts">
useSeoMeta({
title: () => post.value?.title,
description: () => post.value?.description,
ogImage: () => post.value?.image?.src,
})
</script>
Testing
pnpm dev
# Visit:
# http://localhost:3000/sitemap.xml
# http://localhost:3000/robots.txt?mockProductionEnv
The ?mockProductionEnv matters: development always serves Disallow: /, so
without it you are reading the wrong file.
After deployment, validate with:
- Google Search Console — submit the sitemap
- Facebook Sharing Debugger — test OG tags
Learn more
S3 file storage
S3-compatible file storage for uploads and downloads: configure AWS S3, R2 or MinIO, use the server and client services, and choose signed or public URLs.
Mobile apps
Use the Nuxt app as the auth backend for iOS, Android, React Native or Ionic clients: bearer sessions, trusted origins, rate limits, and every endpoint.