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 —
routeRulesand theuseSeoMeta/useHeadcomposables are built in - A CMS module or client exposing content over REST or GraphQL for
useFetch/useAsyncData NUXT_PUBLIC_SITE_URLset to a protocol-prefixed absolute domain in every build environment — resolved viauseRuntimeConfig().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
curlavailable 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.
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.
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.
| 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
- Configuring routeRules for Hybrid Rendering SEO in Nuxt — a focused walkthrough of mapping URL patterns to
prerender,isr,swr, andssrwith per-route headers and redirects
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
- Next.js App Router SEO Configuration — the segment-config equivalent of
routeRulesin Next.js - SvelteKit SEO Configuration — page-option rendering control in SvelteKit
- hreflang & International Routing in Headless — emit reciprocal locale alternates alongside canonicals
- Canonical URL Enforcement — resolve absolute canonicals from runtime config, not the request host
- Crawl Budget Impact in Headless — keep parameterized routes from wasting crawl quota