Dynamic Routes in Vue for SEO

How to configure Vue Router dynamic route params, set per-route meta tags, and avoid duplicate content issues with URL parameters.
Harlan WiltonHarlan Wilton12 mins read Published
What you'll learn
  • Dynamic params create semantic URLs—/blog/:slug instead of /blog?id=123
  • Set per-route meta tags by fetching data before render, not in onMounted()
  • Canonical URLs should strip query params to prevent duplicate content

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.

Route Params vs Query Parameters

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.

Basic Dynamic Routes

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

Access 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>

Per-Route Meta Tags

Search engines need unique titles and descriptions for each dynamic route. Set these with route meta fields and Unhead.

Static Meta for Route Groups

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.

Dynamic Meta from Data

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)
})

Read the SSR rendering guide for full SSR setup.

Multiple Route Params

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>

Optional Params

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'

Catch-All Routes

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>

Programmatic Navigation

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 } })

Common SEO Issues

Issue 1: Duplicate Content from Query Params

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_*

Issue 2: Missing Titles on Dynamic Routes

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>

Issue 3: Client-Side Rendering

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.

Issue 4: Infinite Parameter Variations

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.

Route Params Table

Common dynamic route patterns:

PatternMatchesParamsUse 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

Regex Constraints

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.

TypeScript Support

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

Verification

Check what Google indexes:

  1. View Page Source (not Inspect Element): Right-click → View Page Source. Should show complete HTML with title and meta tags.
  2. Google Search Console URL Inspection: Test Live URL → View HTML. If you see <title>Loading...</title>, your SSR isn't working.
  3. Curl test:
curl https://yoursite.com/blog/vue-guide | grep "<title>"

Should return full title tag, not empty or "Loading".

Using Nuxt?

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.

Learn more in Nuxt →