Nuxt SEO Configuration

Nuxt 3 centralizes rendering decisions in a single routeRules map and injects metadata through composables that run during server-side setup β€” so the same config file that decides whether a route is static, incremental, or server-rendered also governs what a crawler sees. Getting routeRules, useSeoMeta, and server-side canonical wiring right is what keeps CMS-driven pages fully indexable across a mixed-volatility site.

Prerequisites

Confirm the following before configuring Nuxt for SEO:

  • Nuxt 3.x running on Nitro β€” routeRules and the useSeoMeta/useHead composables are built in
  • A CMS module or client exposing content over REST or GraphQL for useFetch/useAsyncData
  • NUXT_PUBLIC_SITE_URL set to a protocol-prefixed absolute domain in every build environment β€” resolved via useRuntimeConfig().public.siteUrl, never from the request hostname
  • A deploy target for Nitro (Node server, or an edge preset such as Cloudflare) that honors per-route cache headers
  • curl available in CI to read response headers and confirm each route’s rendering mode

If your content spans multiple languages, pair this setup with hreflang and international routing in headless so locale alternates are emitted correctly per route.

How a Request Resolves Through routeRules to the Crawler

The diagram shows a request matching a routeRules pattern, picking up its rendering mode, gaining metadata from useSeoMeta, and being served by Nitro.

Nuxt routeRules SEO flow A five-stage horizontal flow showing a Nuxt request matching a routeRules pattern, resolving a rendering mode, applying useSeoMeta metadata, being rendered by Nitro, and reaching the crawler as HTML. Request URL pattern routeRules prerender / isr swr / ssr useSeoMeta canonical + meta Nitro render output Crawler receives HTML

Step-by-Step Implementation Workflow

Step 1 β€” Define routeRules per URL pattern

Map each section of the site to a rendering mode in nuxt.config. Glob patterns let volatile and stable content coexist:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/blog/**': { isr: 3600 },              // regenerate at most hourly
    '/products/**': { swr: 600 },           // stale-while-revalidate 10 min
    '/account/**': { ssr: false },          // client-only, keep out of the index
  },
});

Match the mode to how often the content changes and whether crawlers must read it. Anything indexable must stay server-rendered (prerender, isr, swr, or default ssr), never ssr: false.

Step 2 β€” Set metadata with useSeoMeta

Inside the page’s setup, call useSeoMeta with values pulled from the CMS:

<!-- pages/blog/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: entry } = await useFetch(`/api/cms/${route.params.slug}`);

useSeoMeta({
  title: () => entry.value?.title,
  description: () => entry.value?.description,
  ogTitle: () => entry.value?.title,
  ogType: 'article',
});
</script>

Because setup runs on the server for SSR and prerendered routes, these tags are serialized into the initial HTML.

Step 3 β€” Emit canonical and hreflang server-side

Add a useHead link array that resolves absolute URLs from runtime config:

<script setup lang="ts">
const route = useRoute();
const { public: { siteUrl } } = useRuntimeConfig();
const canonical = `${siteUrl}${route.path}`;

useHead({
  link: [
    { rel: 'canonical', href: canonical },
    { rel: 'alternate', hreflang: 'en', href: `${siteUrl}/en${route.path}` },
    { rel: 'alternate', hreflang: 'de', href: `${siteUrl}/de${route.path}` },
    { rel: 'alternate', hreflang: 'x-default', href: canonical },
  ],
});
</script>

Building from siteUrl β€” not the request hostname β€” is what keeps staging domains out of production tags, the same principle covered in canonical URL enforcement.

Step 4 β€” Wire the sitemap and robots routes

Expose the sitemap as a Nitro server route driven by CMS data:

// server/routes/sitemap.xml.ts
export default defineEventHandler(async (event) => {
  const { public: { siteUrl } } = useRuntimeConfig();
  const slugs = await $fetch<string[]>('/api/cms/slugs');
  const urls = slugs
    .map((s) => `<url><loc>${siteUrl}/blog/${s}</loc></url>`)
    .join('');
  setHeader(event, 'Content-Type', 'application/xml');
  return `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`;
});

Step 5 β€” Validate the applied rendering mode

curl each route and inspect cache and age headers to confirm routeRules applied the mode you intended. See the Validation Protocol section below.

Framework-Specific Code Examples

Nuxt 3

Per-route cache-control and redirects also live in routeRules, so headers stay declarative alongside the rendering mode:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/blog/**': {
      isr: 3600,
      headers: { 'cache-control': 's-maxage=3600, stale-while-revalidate=60' },
    },
    '/old-path': { redirect: { to: '/new-path', statusCode: 301 } },
  },
});

SEO impact: The isr window keeps blog HTML fresh without a full rebuild, the s-maxage header lets the CDN serve cached HTML to crawlers, and the 301 rule consolidates link equity β€” all without per-request server logic that could slow crawler TTFB.

Validation: curl -sI https://example.com/blog/my-post | grep -i 'cache-control\|age' β€” the header must reflect the routeRules value.

Next.js App Router

The nearest Next.js equivalent is route segment config: export const revalidate mirrors isr, and redirects() in next.config mirrors the redirect rule. The full pattern is documented in Next.js App Router SEO Configuration.

// app/blog/[slug]/page.tsx
export const revalidate = 3600; // ISR window, equivalent to Nuxt isr: 3600

SEO impact: Matches Nuxt’s incremental regeneration so crawlers receive cached static HTML that refreshes on a fixed window.

Validation: Confirm the route shows an ISR marker in the Next.js build output.

SvelteKit

SvelteKit expresses rendering mode through page options rather than a central map β€” export const prerender and ssr on each route. See SvelteKit SEO Configuration for the equivalent wiring.

// src/routes/blog/[slug]/+page.ts
export const prerender = true; // equivalent to Nuxt prerender: true

SEO impact: Produces static HTML at build time, matching a Nuxt prerender rule.

Validation: Confirm the route is listed under prerendered pages in the SvelteKit build log.

HTTP Headers & CDN Directives Reference

Header Required value Rationale
Cache-Control s-maxage=3600, stale-while-revalidate=60 Lets the CDN serve cached HTML to crawlers while isr/swr refreshes in the background
Content-Type text/html; charset=utf-8 Ensures rendered routes are parsed as HTML
Link <https://example.com/blog/my-post>; rel="canonical" Optional HTTP-layer canonical for edge-served routes
X-Robots-Tag index, follow Confirms Nitro is not blocking indexation of production routes
Content-Type (sitemap) application/xml Required so the Nitro sitemap route is parsed by crawlers

Validation Protocol

Confirm the rendering mode per route

# ISR/SWR routes expose CDN cache + age headers; pure SSR does not
curl -sI https://example.com/blog/my-post | grep -i 'cache-control\|age\|x-nitro'

An age header that increments on repeated requests confirms the route is being served from the incremental cache rather than rendered fresh each time.

Confirm canonical and hreflang in the raw HTML

curl -s https://example.com/blog/my-post | grep -i 'canonical\|hreflang'

Every declared locale plus x-default must appear, and the canonical must be absolute and match the served path.

Assert SEO tags in Lighthouse CI

// lighthouserc.js
module.exports = {
  assert: {
    assertions: {
      'canonical': ['error', { minScore: 1 }],
      'meta-description': ['error', { minScore: 1 }],
      'hreflang': ['error', { minScore: 1 }],
    },
  },
};

Troubleshooting

Symptom Root cause Fix
Metadata missing in view-source Route matched an ssr: false rule Change the routeRules entry to a server-rendered mode for indexable content
Canonical shows localhost or staging domain Built from request hostname instead of runtime config Resolve from useRuntimeConfig().public.siteUrl in useHead
ISR content never updates No revalidation window or webhook to purge Set isr: <seconds> and purge on CMS publish
hreflang tags omitted on some locales useHead link array missing entries Generate one alternate per locale plus x-default from the locale list
Crawl budget wasted on faceted params No rule excluding parameterized URLs Add canonical/noindex handling β€” see crawl budget impact in headless

Pages in This Section

Frequently Asked Questions

What rendering modes can routeRules set? routeRules can set prerender for build-time static HTML, isr for incremental static regeneration with a revalidation window, swr for stale-while-revalidate caching, and ssr: false for a client-only shell. Each rule is keyed by a URL glob so different sections of the site can use different modes. Keep anything indexable on a server-rendered mode.

How do I set canonical and hreflang server-side in Nuxt? Call useHead in the page setup with a link array containing a rel="canonical" entry and one rel="alternate" hreflang entry per locale, building absolute URLs from useRuntimeConfig().public.siteUrl. Because setup runs on the server for SSR and prerendered routes, the tags land in the initial HTML. For full locale coverage, follow hreflang and international routing in headless.

Does useSeoMeta render tags in the initial HTML? Yes, for server-rendered and prerendered routes. useSeoMeta runs during setup, which executes on the server, so the title, description, and Open Graph tags are serialized into the first HTML response. On routes forced to client-only rendering the tags are added after hydration instead, which crawlers may not see.


Part of: Framework-Specific SEO Configuration for Headless Stacks

Related