/blog/:slug instead of /blog?id=123onMounted()Dynamic routes generate clean URLs from parameters. /blog/:slug creates /blog/vue-seo-guide instead of /blog?id=123. Search engines prefer semantic paths over query parameters.
Vue Router's dynamic route matching handles this automatically. Configure your routes once, set per-route meta tags, 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.
Define routes with :param syntax:
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/blog/:slug',
component: BlogPost
},
{
path: '/products/:category/:id',
component: Product
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
This generates:
/blog/vue-ssr-guide/products/electronics/123Access params in components via route.params:
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const slug = route.params.slug // "vue-ssr-guide"
</script>
Search engines need unique titles and descriptions for each dynamic route. Set these with route meta fields and Unhead.
Define generic meta in route config:
const routes = [
{
path: '/blog/:slug',
component: BlogPost,
meta: {
title: 'Blog',
description: 'Read our latest articles'
}
}
]
Apply meta in a navigation guard:
router.afterEach((to) => {
useHead({
title: to.meta.title as string,
titleTemplate: '%s | MySite'
})
})
This works for basic cases but gives every blog post the same title ("Blog | MySite"). Override in components for dynamic content.
Fetch data and set specific meta tags per page:
<script setup lang="ts">
import { useRoute } from 'vue-router'
const route = useRoute()
const post = await fetchPost(route.params.slug)
// Overrides route meta with actual post data
useHead({
title: post.title, // "How to Build a Vue Blog"
meta: [
{ name: 'description', content: post.excerpt }
]
})
// Or use useSeoMeta for SEO-specific tags
useSeoMeta({
title: post.title,
description: post.excerpt,
ogTitle: post.title,
ogDescription: post.excerpt,
ogImage: post.coverImage
})
</script>
<template>
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.content }}</p>
</article>
</template>
SSR requirement: Fetch data before render, not in onMounted(). Client-side fetches mean search engines see loading states. Use SSR-compatible patterns:
// server.ts
import express from 'express'
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
const app = express()
app.get('/blog/:slug', async (req, res) => {
const post = await fetchPost(req.params.slug)
const vueApp = createSSRApp({
setup() {
useHead({
title: post.title,
meta: [{ name: 'description', content: post.excerpt }]
})
}
})
const html = await renderToString(vueApp)
res.send(html)
})
// server.js
import { createSSRApp } from 'vue'
import { renderToString } from 'vue/server-renderer'
export async function render(url, manifest) {
const app = createSSRApp(App)
// Prefetch data on server
await router.push(url)
await router.isReady()
const post = await fetchPost(router.currentRoute.value.params.slug)
app.use(head)
useHead({
title: post.title,
meta: [{ name: 'description', content: post.excerpt }]
})
const html = await renderToString(app)
return { html }
}
// server/routes/blog/[slug].get.ts
import { defineEventHandler } from 'h3'
export default defineEventHandler(async (event) => {
const slug = event.context.params.slug
const post = await fetchPost(slug)
const app = createSSRApp({
setup() {
useHead({
title: post.title,
meta: [{ name: 'description', content: post.excerpt }]
})
}
})
return renderToString(app)
})
Read the SSR rendering guide for full SSR setup.
Combine params for hierarchical URLs:
const routes = [
{
path: '/docs/:category/:page',
component: DocPage
}
]
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 doc = await fetchDoc(category, page)
useSeoMeta({
title: doc.title,
description: doc.excerpt
})
</script>
Make params optional with ?:
const routes = [
{
path: '/blog/:category?/:slug',
component: BlogPost
}
]
Matches both:
/blog/vue-guide (category undefined)/blog/tutorials/vue-guide (category = "tutorials")Handle undefined params in components:
const route = useRoute()
const category = route.params.category || 'general'
Use * or :pathMatch(.*)* for 404 pages:
const routes = [
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: NotFound
}
]
Set appropriate meta for 404s:
<script setup lang="ts">
useHead({
title: '404 - Page Not Found',
meta: [
{ name: 'robots', content: 'noindex, nofollow' }
]
})
</script>
Navigate to dynamic routes programmatically:
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
function viewPost(slug: string) {
// Updates URL and triggers route meta/title updates
router.push(`/blog/${slug}`)
}
function viewProduct(category: string, id: number) {
router.push({
name: 'Product',
params: { category, id }
})
}
</script>
Named routes prevent typos:
const routes = [
{
path: '/products/:category/:id',
name: 'Product',
component: Product
}
]
// Type-safe with route names
router.push({ name: 'Product', params: { category: 'electronics', id: 123 } })
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 Google's URL parameter handling via robots.txt:
User-agent: *
Disallow: /*?ref=*
Disallow: /*?utm_*
Generic titles hurt SEO:
<!-- ❌ Every blog post shows "Blog | MySite" -->
<title>Blog | MySite</title>
Always override with specific content:
<script setup lang="ts">
const post = await fetchPost(route.params.slug)
// ✅ Each post gets unique title
useHead({
title: post.title // "How to Build a Vue Blog"
})
</script>
SPA routing means search engines see your loading state:
<!-- Google sees this if you fetch in onMounted() -->
<title>Loading...</title>
<h1>Loading...</h1>
Use SSR or prerendering to ship complete HTML. Google can render JavaScript but it's slower and less reliable.
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 or block in robots.txt:
router.afterEach((to) => {
// Noindex pages with query params
if (Object.keys(to.query).length > 0) {
useHead({
meta: [
{ name: 'robots', content: 'noindex, follow' }
]
})
}
})
Read more about controlling crawlers.
Common dynamic route patterns:
| Pattern | Matches | Params | Use Case |
|---|---|---|---|
/blog/:slug | /blog/vue-guide | { slug: 'vue-guide' } | Blog posts, articles |
/products/:id | /products/123 | { id: '123' } | Product pages |
/docs/:category/:page | /docs/api/methods | { category: 'api', page: 'methods' } | Documentation |
/user/:id(\\d+) | /user/42 | { id: '42' } | User profiles (numeric IDs only) |
/:lang/about | /en/about | { lang: 'en' } | Internationalized routes |
/files/:path(.*) | /files/docs/api.md | { path: 'docs/api.md' } | Nested file paths |
Validate params with regex:
const routes = [
{
// Only match numeric IDs
path: '/user/:id(\\d+)',
component: User
},
{
// Only match valid slugs (lowercase, hyphens)
path: '/blog/:slug([a-z0-9-]+)',
component: BlogPost
}
]
Invalid params result in 404, preventing indexing of malformed URLs.
Set global meta patterns in guards:
router.beforeEach((to, from) => {
// Set default meta for all routes
useHead({
titleTemplate: '%s | MySite',
meta: [
{ property: 'og:site_name', content: 'MySite' },
{ name: 'twitter:card', content: 'summary_large_image' }
]
})
})
router.afterEach((to) => {
// Apply route-specific meta
if (to.meta.title) {
useHead({
title: to.meta.title as string
})
}
})
Components can override guard defaults with their own useHead() calls. Unhead merges them with component-level taking precedence.
Type route params and meta:
import 'vue-router'
declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
requiresAuth?: boolean
}
}
// Type-safe route params
interface BlogParams {
slug: string
}
const route = useRoute<BlogParams>()
const slug: string = route.params.slug // Typed
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 handles dynamic routes automatically with file-based routing. pages/blog/[slug].vue creates the route, useSeoMeta() sets tags, and SSR works out of the box.