Dynamic routes generate clean URLs from parameters. /blog/vue-seo-guide instead of /blog?id=123. Search engines prefer semantic paths over query parameters.
Nuxt's file-based routing handles this automatically. Create pages/blog/[slug].vue and Nuxt generates the route. Set per-route meta tags with useSeoMeta() and Google indexes each page correctly.
Google treats these differently:
✅ /products/electronics/laptop
❌ /products?category=electronics&item=laptop
Route params create semantic URLs. Query parameters generate duplicate content issues—Google sees infinite URL variations when you add filters, sorting, or tracking params.
From Google's 2025 URL structure guidelines: use clean paths for important content, reserve query parameters for filters that shouldn't be indexed.
Create files with [param] syntax in the pages/ directory:
pages/
├── blog/
│ └── [slug].vue → /blog/:slug
├── products/
│ └── [category]/
│ └── [id].vue → /products/:category/:id
This generates:
/blog/vue-ssr-guide/products/electronics/123Access params in components via route.params:
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug // "vue-ssr-guide"
</script>
Search engines need unique titles and descriptions for each dynamic route. Fetch data and set meta tags with Nuxt's data fetching composables.
Fetch data and set specific meta tags per page:
<script setup lang="ts">
const route = useRoute()
// Nuxt handles SSR automatically
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
// Or with useAsyncData
const { data: post } = await useAsyncData(`post-${route.params.slug}`, () =>
fetchPost(route.params.slug))
// 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—search engines see complete HTML with correct meta tags. No additional setup required.
For content-driven sites, integrate with @nuxt/content:
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(`post-${route.params.slug}`, () =>
queryContent('/blog').where({ slug: route.params.slug }).findOne())
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>
Combine params with nested directories:
pages/
└── docs/
└── [category]/
└── [page].vue → /docs/:category/:page
Generates: /docs/getting-started/installation
Access all params:
<script setup lang="ts">
const route = useRoute()
const { category, page } = route.params
// Fetch content based on both params
const { data: doc } = await useFetch(`/api/docs/${category}/${page}`)
useSeoMeta({
title: doc.value?.title,
description: doc.value?.excerpt
})
</script>
Nuxt supports optional params with multiple page files:
pages/
└── blog/
├── [slug].vue → /blog/:slug
└── [category]/
└── [slug].vue → /blog/:category/:slug
Matches 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>
Use [...slug].vue for catch-all patterns:
pages/
└── [...slug].vue → Matches any path
Or nested catch-all:
pages/
└── files/
└── [...path].vue → /files/* matches all nested paths
Access 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>
Create a catch-all for 404 handling:
<script setup lang="ts">
useHead({
title: '404 - Page Not Found',
meta: [
{ name: 'robots', content: 'noindex, nofollow' }
]
})
</script>
<template>
<div>
<h1>404 - Page Not Found</h1>
</div>
</template>
Or use Nuxt's dedicated error page:
<script setup lang="ts">
const error = useError()
useHead({
title: `${error.statusCode} - ${error.statusMessage}`,
meta: [
{ name: 'robots', content: 'noindex, nofollow' }
]
})
</script>
<template>
<div>
<h1>{{ error.statusCode }}: {{ error.statusMessage }}</h1>
</div>
</template>
Navigate to dynamic routes with navigateTo():
<script setup lang="ts">
function viewPost(slug: string) {
// Updates URL and triggers meta updates
navigateTo(`/blog/${slug}`)
}
function viewProduct(category: string, id: number) {
navigateTo(`/products/${category}/${id}`)
}
</script>
Or use useRouter():
<script setup lang="ts">
const router = useRouter()
function viewPost(slug: string) {
router.push(`/blog/${slug}`)
}
</script>
Mixing route params and query params creates duplicates:
/blog/vue-guide
/blog/vue-guide?ref=twitter
/blog/vue-guide?utm_source=newsletter
Google indexes these as separate pages. Fix with canonical tags:
<script setup lang="ts">
const route = useRoute()
// Strip query params from canonical URL
const canonicalUrl = computed(() => {
return `https://yoursite.com${route.path}`
})
useHead({
link: [
{ rel: 'canonical', href: canonicalUrl.value }
]
})
</script>
Or use Nuxt SEO Utils which handles this automatically:
Generic titles hurt SEO:
<!-- ❌ Every blog post shows "Blog | MySite" -->
<title>Blog | MySite</title>
Always override with specific content:
<script setup lang="ts">
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
// ✅ Each post gets unique title
useSeoMeta({
title: post.value?.title // "How to Build a Vue Blog"
})
</script>
Dynamic routes with filters create crawl budget waste:
// ❌ Generates thousands of URLs
/products/:category?sort=price
/products/:category?sort=name
/products/:category?color=red&sort=price
// ... infinite combinations
Use noindex on filtered pages with route middleware:
export default defineNuxtRouteMiddleware((to) => {
// Noindex pages with query params
if (Object.keys(to.query).length > 0) {
useHead({
meta: [
{ name: 'robots', content: 'noindex, follow' }
]
})
}
})
Or block in robots.txt with the Robots module:
export default defineNuxtConfig({
robots: {
disallow: [
'/*?ref=*',
'/*?utm_*'
]
}
})
Read more about controlling crawlers.
Return proper 404 status codes for missing content:
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useFetch(`/api/posts/${route.params.slug}`)
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.
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 |
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.
Type route params:
// Type-safe route params
interface BlogParams {
slug: string
}
const route = useRoute<BlogParams>()
const slug: string = route.params.slug // Typed
Or use Nuxt's generated types:
<script setup lang="ts">
// Nuxt auto-generates route types
const route = useRoute('blog-slug') // Type-safe params based on file structure
</script>
Check what Google indexes:
<title>Loading...</title>, your SSR isn't working.curl https://yoursite.com/blog/vue-guide | grep "<title>"
Should return full title tag, not empty or "Loading".
Nuxt's SSR works by default—you should always see complete HTML in the source.