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 NitrorouteRules 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

Route rules are matched by specificity, so the resolved rule for a path is not necessarily the one that appears closest to it in the config.

Route rule specificity for one path Three route rule patterns that all match a path, with the most specific pattern determining the resolved behaviour. matching patterns /** — ssr true /blog/** — isr 3600 /blog/live/** — ssr, no cache /blog/live/match-report resolves to the third rule Test the resolved rule for a path rather than reading the object top to bottom

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

Nuxt’s default is server rendering, so most SEO defects here come from opting a route out rather than from failing to opt one in.

Server rendering by default, opt-out by exception A default server-rendered baseline with a small opt-out region, marked as the origin of most rendering-related SEO defects. server rendered by default — crawlable ssr: false check nothing indexable ended up in here Audit the opt-out list, not the default
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

Route rules as a reviewable policy document

The most valuable property of Nuxt’s route-rules object is not that it configures rendering but that it is short enough to read. A reviewer can open one file and answer the question “which parts of this site are crawlable, and how fresh is each one?” — a question that in most stacks requires reading dozens of files or, more often, going unanswered.

That property is worth protecting deliberately. Resist the temptation to compute rules programmatically or spread them across imported fragments; the moment the object stops being readable at a glance, it loses the advantage that motivated using it. If the rule count grows past what fits on a screen, that is usually a sign the URL structure has more shapes than it needs rather than a sign the config format is inadequate.

It is also worth keeping the object aligned with how the site is actually organised. Rules keyed on the same prefixes as your content sections make the mapping between “what kind of page is this” and “how is it rendered” obvious. Rules keyed on incidental path fragments — a legacy prefix, a framework-internal route — make it opaque, and opacity here is expensive because it is the layer everything else depends on.

Where Nuxt’s defaults help and where they hide problems

Nuxt renders on the server unless told otherwise, which is the right default and removes an entire class of accidental client-only pages. The trade-off is that a route can be server-rendered and still be wrong: the HTML arrives, the metadata is absent, and nothing in the framework complains because rendering succeeded.

The practical response is to check for presence rather than for rendering. Assert that the served HTML contains a canonical, a description, and a JSON-LD block, rather than assuming that a server-rendered route is a complete one. Nuxt’s default gets you the body; the assertions get you the head, and it is the head that decides how the body is treated.

The module ecosystem as a dependency decision

Much of Nuxt’s SEO convenience arrives through modules, and modules are dependencies with the properties dependencies have: a release cadence you do not control, a maintainer whose priorities may shift, and a compatibility surface that a framework major can break. That is a reasonable trade for capability you would otherwise build, and an unreasonable one for capability you cannot ship without.

The distinction worth making is between modules that add convenience and modules that provide something structural. A module that generates Open Graph images is convenience: if it lags a major version, you ship without generated images for a few weeks. A module that generates your sitemap is structural: if it lags, your discovery layer silently stops working and nothing in the build reports it.

For the structural cases, the safer pattern is to keep the module for development ergonomics but assert the output independently. A short check that fetches the sitemap after build and confirms it contains the expected URL count turns a silent dependency failure into a red build, which is all the protection this actually needs.

Nitro presets change more than deployment

The adapter preset Nuxt builds for determines which runtime APIs are available, how route rules are compiled, and in some cases whether incremental regeneration is supported at all. Two builds of identical source can therefore behave differently in ways that matter for crawling, purely because they targeted different platforms.

That makes the preset worth treating as part of the SEO configuration rather than as a deployment detail. Verify the rendering behaviour on the preset you actually ship, not on the Node preset the development server uses, and re-verify whenever the hosting target changes — which is the point at which this difference reliably surprises people.

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