LogoRapidstack.pro

SEO and metadata

SEO in the boilerplate: sitemap generation, robots.txt, branded page titles, canonical URLs, and OpenGraph and Twitter Card tags from the Nuxt SEO modules.

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, and Disallow: / for non-production deploys
  • nuxt-seo-utils — title template, rel=canonical, and the OpenGraph and Twitter defaults
nuxt.config.ts
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:

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 docs content 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():

content.config.ts
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:

server/api/__sitemap__/urls.ts
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:

nuxt.config.ts
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:

nuxt.config.ts
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:

nuxt.config.ts
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:

app/pages/about.vue
<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:

app/components/post/PostPage.vue
<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:

Learn more

Nuxt SEO

Comprehensive SEO modules and documentation.

Nuxt Sitemap

Advanced sitemap configuration and features.

OpenGraph Protocol

Learn about OpenGraph meta tags for social sharing.