---
title: "Dynamic Routes in Vue for SEO"
description: "How to configure Vue Router dynamic route params, set per-route meta tags, and avoid duplicate content issues with URL parameters."
canonical_url: "https://nuxtseo.com/learn-seo/vue/routes-and-rendering/dynamic-routes"
last_updated: "2025-12-17"
---

<key-takeaways>

- 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

</key-takeaways>

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](https://router.vuejs.org/guide/essentials/dynamic-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:

```text
✅ /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](https://developers.google.com/search/docs/crawling-indexing/url-structure): use clean paths for important content, reserve query parameters for filters that shouldn't be indexed.

## Basic Dynamic Routes

Define routes with `:param` syntax:

```ts
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`:

```vue
<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](https://router.vuejs.org/guide/advanced/meta.html) and Unhead.

### Static Meta for Route Groups

Define generic meta in route config:

```ts
const routes = [
  {
    path: '/blog/:slug',
    component: BlogPost,
    meta: {
      title: 'Blog',
      description: 'Read our latest articles'
    }
  }
]
```

Apply meta in a navigation guard:

```ts
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:

```vue
<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:

<code-group>

```ts [Express]
// 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)
})
```

```ts [Vite SSR]
// 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 }
}
```

```ts [H3]
// 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)
})
```

</code-group>

Read the [SSR rendering guide](/learn-seo/vue/routes-and-rendering/rendering) for full SSR setup.

## Multiple Route Params

Combine params for hierarchical URLs:

```ts
const routes = [
  {
    path: '/docs/:category/:page',
    component: DocPage
  }
]
```

Generates: `/docs/getting-started/installation`

Access all params:

```vue
<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 `?`:

```ts
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:

```ts
const route = useRoute()
const category = route.params.category || 'general'
```

## Catch-All Routes

Use `*` or `:pathMatch(.*)*` for 404 pages:

```ts
const routes = [
  {
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: NotFound
  }
]
```

Set appropriate meta for 404s:

```vue
<script setup lang="ts">
useHead({
  title: '404 - Page Not Found',
  meta: [
    { name: 'robots', content: 'noindex, nofollow' }
  ]
})
</script>
```

## Programmatic Navigation

Navigate to dynamic routes programmatically:

```vue
<script setup lang="ts">
import { useRouter } from 'vue-router'

const router = useRouter()

function viewPost(slug: string) {
  // Updates URL and triggers route meta/title updates
  navigateTo(`/blog/${slug}`)
}

function viewProduct(category: string, id: number) {
  navigateTo({
    name: 'Product',
    params: { category, id }
  })
}
</script>
```

Named routes prevent typos:

```ts
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:

```text
/blog/vue-guide
/blog/vue-guide?ref=twitter
/blog/vue-guide?utm_source=newsletter
```

Google indexes these as separate pages. Fix with canonical tags:

```vue
<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](https://developers.google.com/search/docs/crawling-indexing/url-structure) via `robots.txt`:

```text
User-agent: *
Disallow: /*?ref=*
Disallow: /*?utm_*
```

### Issue 2: Missing Titles on Dynamic Routes

Generic titles hurt SEO:

```html
<!-- ❌ Every blog post shows "Blog | MySite" -->
<title>Blog | MySite</title>
```

Always override with specific content:

```vue
<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:

```html
<!-- Google sees this if you fetch in onMounted() -->
<title>Loading...</title>
<h1>Loading...</h1>
```

Use SSR or [prerendering](/learn-seo/vue/spa/prerendering) to ship complete HTML. Google [can render JavaScript](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics) but it's slower and less reliable.

### Issue 4: Infinite Parameter Variations

Dynamic routes with filters create crawl budget waste:

```text
// ❌ 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`:

```ts
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](/learn-seo/vue/controlling-crawlers).

## Route Params Table

Common dynamic route patterns:

<table>
<thead>
  <tr>
    <th>
      Pattern
    </th>
    
    <th>
      Matches
    </th>
    
    <th>
      Params
    </th>
    
    <th>
      Use Case
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        /blog/:slug
      </code>
    </td>
    
    <td>
      <code>
        /blog/vue-guide
      </code>
    </td>
    
    <td>
      <code>
        { slug: 'vue-guide' }
      </code>
    </td>
    
    <td>
      Blog posts, articles
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /products/:id
      </code>
    </td>
    
    <td>
      <code>
        /products/123
      </code>
    </td>
    
    <td>
      <code>
        { id: '123' }
      </code>
    </td>
    
    <td>
      Product pages
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /docs/:category/:page
      </code>
    </td>
    
    <td>
      <code>
        /docs/api/methods
      </code>
    </td>
    
    <td>
      <code>
        { category: 'api', page: 'methods' }
      </code>
    </td>
    
    <td>
      Documentation
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes github-light github-light material-theme-palenight" language="ts" style="">
        <span class="sbw7o">
          /
        </span>
        
        <span class="sJnJ8">
          user
        </span>
        
        <span class="sbw7o">
          /
        </span>
        
        <span class="sqjlB">
          :
        </span>
        
        <span class="s0YkB">
          id
        </span>
        
        <span class="sqjlB">
          (\\d
        </span>
        
        <span class="sc1V3">
          +
        </span>
        
        <span class="sqjlB">
          )
        </span>
      </code>
    </td>
    
    <td>
      <code>
        /user/42
      </code>
    </td>
    
    <td>
      <code>
        { id: '42' }
      </code>
    </td>
    
    <td>
      User profiles (numeric IDs only)
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /:lang/about
      </code>
    </td>
    
    <td>
      <code>
        /en/about
      </code>
    </td>
    
    <td>
      <code>
        { lang: 'en' }
      </code>
    </td>
    
    <td>
      Internationalized routes
    </td>
  </tr>
  
  <tr>
    <td>
      <code className="language-ts shiki shiki-themes github-light github-light material-theme-palenight" language="ts" style="">
        <span class="sbw7o">
          /
        </span>
        
        <span class="sJnJ8">
          files
        </span>
        
        <span class="sbw7o">
          /
        </span>
        
        <span class="sqjlB">
          :
        </span>
        
        <span class="s0YkB">
          path
        </span>
        
        <span class="sqjlB">
          (
        </span>
        
        <span class="sx-uw">
          .
        </span>
        
        <span class="sc1V3">
          *
        </span>
        
        <span class="sqjlB">
          )
        </span>
      </code>
    </td>
    
    <td>
      <code>
        /files/docs/api.md
      </code>
    </td>
    
    <td>
      <code>
        { path: 'docs/api.md' }
      </code>
    </td>
    
    <td>
      Nested file paths
    </td>
  </tr>
</tbody>
</table>

## Regex Constraints

Validate params with regex:

```ts
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.

## Navigation Guards for Meta

Set global meta patterns in guards:

```ts
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:

```ts
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**:

```bash
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 by default.

[Learn more in Nuxt →](/learn-seo/nuxt/routes-and-rendering)
