Dynamic Routes in Nuxt for SEO
- Route params and query parameters can identify indexable pages. Choose readable URLs and classify equivalent variants
- Await data during SSR to include it in the initial response; Google can also render JavaScript
- Remove tracking parameters from canonical URLs when they do not change content; preserve distinct content parameters
- Invalid route params should return a real 404, not a rendered page with empty content
Dynamic routes generate clean URLs from parameters: /blog/vue-seo-guide instead of /blog?id=123. Descriptive paths help readers understand the destination; query URLs can also be indexed.
Route Params vs Query Parameters
Both paths and query URLs can identify indexable content:
/products/electronics/laptop
/products?category=electronics&item=laptopChoose descriptive, readable URLs. Compare filter, sort, and pagination content before selecting canonical or indexing policies. See Google’s URL guidelines.
Basic Dynamic Routes
Create files with [param] syntax in the pages/ directory:
pages/
├── blog/
│ └── [slug].vue → /blog/:slug
├── products/
│ └── [category]/
│ └── [id].vue → /products/:category/:idThis generates:
/blog/vue-ssr-guide/products/electronics/123
Access params in components via route.params:
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug // "vue-ssr-guide"
</script>Per-Route Meta Tags
Use titles and descriptions that identify the actual route content. Fetch data and set meta tags with Nuxt's data fetching composables.
Dynamic Meta from Data
This initial-load example requires an app-owned post endpoint returning title, excerpt, and an absolute coverImage URL. Handle route changes separately if the component remains mounted for another slug.
This integration excerpt assumes an app-owned /api/posts/:slug endpoint returning title, excerpt, coverImage, and content. Handle a missing post with a 404 before rendering metadata:
<script setup lang="ts">
const route = useRoute()
// This example assumes SSR is enabled
const { data: post, error } = await useFetch(`/api/posts/${encodeURIComponent(String(route.params.slug))}`)
if (error.value)
throw error.value
if (!post.value)
throw createError({ statusCode: 404, statusMessage: 'Post not found' })
// Set SEO meta tags
useSeoMeta({
title: () => post.value?.title,
description: () => post.value?.excerpt,
ogTitle: () => post.value?.title,
ogDescription: () => post.value?.excerpt,
ogImage: () => post.value?.coverImage
})
</script>
<template>
<article>
<h1>{{ post?.title }}</h1>
<p>{{ post?.content }}</p>
</article>
</template>Nuxt's useFetch() and useAsyncData() work automatically during SSR, so fetched data can be included in the initial HTML. The endpoint and missing-data handling still belong to your app.
Using Nuxt Content
This initial-load integration excerpt requires a configured blog Content collection with a slug field, title, and description. Handle route changes separately:
<script setup lang="ts">
const route = useRoute()
const { data: post, error } = await useAsyncData(`post-${route.params.slug}`, () =>
queryCollection('blog').where('slug', '=', route.params.slug).first())
if (error.value)
throw error.value
if (!post.value) {
throw createError({
statusCode: 404,
statusMessage: 'Post not found'
})
}
useSeoMeta({
title: post.value.title,
description: post.value.description
})
</script>
<template>
<ContentRenderer :value="post" />
</template>Multiple Route Params
Combine params with nested directories:
pages/
└── docs/
└── [category]/
└── [page].vue → /docs/:category/:pageGenerates: /docs/getting-started/installation
This initial-load excerpt requires an app-owned /api/docs/:category/:page endpoint returning title and excerpt. Endpoint failures propagate; an absent document produces 404:
<script setup lang="ts">
const route = useRoute()
const { category, page } = route.params
// Fetch content based on both params
const { data: doc, error } = await useFetch(`/api/docs/${encodeURIComponent(String(category))}/${encodeURIComponent(String(page))}`)
if (error.value)
throw error.value
if (!doc.value)
throw createError({ statusCode: 404, statusMessage: 'Document not found' })
useSeoMeta({
title: doc.value?.title,
description: doc.value?.excerpt
})
</script>Optional Params
These two files implement two route shapes. For a single optional parameter, Nuxt supports [[param]].vue syntax:
pages/
└── blog/
├── [slug].vue → /blog/:slug
└── [category]/
└── [slug].vue → /blog/:category/:slugMatches both:
/blog/vue-guide(category undefined)/blog/tutorials/vue-guide(category = "tutorials")
Handle optional params in components:
<script setup lang="ts">
const route = useRoute()
const category = route.params.category || 'general'
</script>Catch-All Routes
Use [...slug].vue for catch-all patterns:
pages/
└── [...slug].vue → Matches any pathOr nested catch-all:
pages/
└── files/
└── [...path].vue → /files/* matches all nested pathsAccess the full path:
<script setup lang="ts">
const route = useRoute()
const path = route.params.path // Array: ['docs', 'api.md']
const fullPath = Array.isArray(path) ? path.join('/') : path
</script>For missing routes, throw createError({ statusCode: 404 }) from page setup instead of rendering a 404 message with status 200. Use app/error.vue to display the error. See the 404 guide for data and HTTP checks.
Programmatic Navigation
Navigate to dynamic routes with navigateTo():
<script setup lang="ts">
function viewPost(slug: string) {
// Destination metadata depends on that page’s data and head setup
navigateTo(`/blog/${slug}`)
}
function viewProduct(category: string, id: number) {
navigateTo(`/products/${category}/${id}`)
}
</script>The navigateTo example above changes the URL. Metadata and data updates depend on the destination page’s setup.
Common SEO Issues
Issue 1: Duplicate Content from Query Params
Tracking parameters that do not change this article’s content create equivalent URL variants:
/blog/vue-guide
/blog/vue-guide?ref=twitter
/blog/vue-guide?utm_source=newsletterThese tracking variants can show equivalent content. Set a canonical preference rather than assuming Google indexes every variant:
<script setup lang="ts">
const route = useRoute()
// This article route has no content-changing query parameters
const canonicalUrl = computed(() => {
return `https://yoursite.com${route.path}`
})
useHead(() => ({
link: [
{ rel: 'canonical', href: canonicalUrl.value }
]
}))
</script>Alternatively, configure SEO Utils with your app’s canonical query policy and inspect the output:
Issue 2: Missing Titles on Dynamic Routes
Generic titles hurt SEO:
<!-- ❌ Every blog post shows "Blog | MySite" -->
<title>Blog | MySite</title>Use the complete Dynamic Meta from Data example above. It defines the route, handles missing data, and propagates endpoint errors before emitting metadata.
Issue 3: Infinite Parameter Variations
Filters can produce many URLs. Some show distinct useful content; others show equivalent combinations. Select an indexing policy from the rendered content. Do not noindex every query URL or block tracking URLs when Google needs to read their canonical tags. See the query parameter guide.
Read more about controlling crawlers.
Issue 4: 404 Handling
This initial-load excerpt uses the app-owned post endpoint described above. Propagate its failures and distinguish successful empty data:
<script setup lang="ts">
const route = useRoute()
const { data: post, error } = await useFetch(`/api/posts/${encodeURIComponent(String(route.params.slug))}`)
if (error.value)
throw error.value
if (!post.value) {
throw createError({
statusCode: 404,
statusMessage: 'Post not found'
})
}
</script>Nuxt automatically sets the response status code to 404 during SSR, preventing Google from indexing non-existent pages. If you're seeing unexpected 404s in Search Console, see the indexing issues guide for diagnosis steps.
Route Params Table
Common dynamic route patterns:
| File Pattern | Matches | Params | Use Case |
|---|---|---|---|
pages/blog/[slug].vue | /blog/vue-guide | { slug: 'vue-guide' } | Blog posts, articles |
pages/products/[id].vue | /products/123 | { id: '123' } | Product pages |
pages/docs/[category]/[page].vue | /docs/api/methods | { category: 'api', page: 'methods' } | Documentation |
pages/user/[id].vue | /user/42 | { id: '42' } | User profiles |
pages/[lang]/about.vue | /en/about | { lang: 'en' } | Internationalized routes |
pages/files/[...path].vue | /files/docs/api.md | { path: ['docs', 'api.md'] } | Nested file paths |
Route Validation
Validate params with definePageMeta():
<script setup lang="ts">
definePageMeta({
validate: async (route) => {
// Only allow numeric IDs
return /^\d+$/.test(route.params.id as string)
}
})
</script>Invalid params result in 404, preventing indexing of malformed URLs.
TypeScript Support
Nuxt can generate typed routes when you enable the supported typed-pages option for your version. Do not pass a custom params interface to useRoute() as though it guarantees runtime validation.
Read the Nuxt pages reference for current typed-route and optional-parameter support. Validate untrusted parameters before loading content.
Verification
Check the direct response and rendered HTML. Use URL Inspection’s indexed-state information separately to check Google’s stored state:
- View Page Source (not Inspect Element): Right-click → View Page Source. Should show complete HTML with title and meta tags.
- Google Search Console URL Inspection: Test Live URL → View HTML. A loading title can indicate delayed or failed data loading. Compare the response, rendered HTML, and endpoint result before identifying the cause.
- Curl test:
curl https://yoursite.com/blog/vue-guide | grep "<title>"Should return full title tag, not empty or "Loading".
Nuxt enables SSR by default. Inspect the response for your route and data state; configuration or failed data loading can change the output.
Checklist
- Dynamic routes use readable paths or query URLs suited to their content
- Meta tags are set from fetched data with
useSeoMeta()oruseHead(), not left generic - Canonical URLs remove content-neutral tracking parameters and preserve distinct content parameters
- Missing content throws
createError({ statusCode: 404 })instead of rendering an empty page - Filter and sort policies distinguish equivalent content from useful distinct pages; canonical and noindex targets remain crawlable
- View Source and Search Console's URL Inspection both show the real title, not a loading state
Pre-launch setup, post-launch verification, and ongoing monitoring. Interactive checklist with links to every guide.