/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.