---
title: "Dynamic Routes in Nuxt for SEO"
description: "How to use Nuxt's file-based dynamic routes, set per-route meta tags, and avoid duplicate content issues with URL parameters."
canonical_url: "https://nuxtseo.com/learn-seo/nuxt/routes-and-rendering/dynamic-routes"
last_updated: "2026-01-29"
---

Dynamic routes generate clean URLs from parameters. `/blog/vue-seo-guide` instead of `/blog?id=123`. Search engines prefer semantic paths over query parameters. In 2026, Nuxt 4's optimized **Soft Navigations** ensure that transition between dynamic routes are nearly instant and fully indexable.

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

Create files with `[param]` syntax in the `pages/` directory:

```text
pages/
├── blog/
│   └── [slug].vue          → /blog/:slug
├── products/
│   └── [category]/
│       └── [id].vue        → /products/:category/:id
```

This generates:

- `/blog/vue-ssr-guide`
- `/products/electronics/123`

Access params in components via `route.params`:

```vue [pages/blog/[slug].vue]
<script setup lang="ts">
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. Fetch data and set meta tags with Nuxt's data fetching composables.

### Dynamic Meta from Data

Fetch data and set specific meta tags per page:

```vue [pages/blog/[slug].vue]
<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.

### Using Nuxt Content

For content-driven sites, integrate with `@nuxt/content`:

```vue [pages/blog/[slug].vue]
<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>
```

## Multiple Route Params

Combine params with nested directories:

```text
pages/
└── docs/
    └── [category]/
        └── [page].vue      → /docs/:category/:page
```

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

Access all params:

```vue [pages/docs/[category]/[page].vue]
<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>
```

## Optional Params

Nuxt supports optional params with multiple page files:

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

```vue
<script setup lang="ts">
const route = useRoute()
const category = route.params.category || 'general'
</script>
```

## Catch-All Routes

Use `[...slug].vue` for catch-all patterns:

```text
pages/
└── [...slug].vue           → Matches any path
```

Or nested catch-all:

```text
pages/
└── files/
    └── [...path].vue       → /files/* matches all nested paths
```

Access the full path:

```vue [pages/files/[...path].vue]
<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>
```

### 404 Pages

Create a catch-all for 404 handling:

```vue [pages/[...slug].vue]
<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:

```vue [error.vue]
<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>
```

## Programmatic Navigation

Navigate to dynamic routes with `navigateTo()`:

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

```vue
<script setup lang="ts">
const router = useRouter()

function viewPost(slug: string) {
  navigateTo(`/blog/${slug}`)
}
</script>
```

## 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 Nuxt SEO Utils which handles this automatically:

<module-card className="w-1/2" slug="seo-utils">



</module-card>

### 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 { 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>
```

### Issue 3: 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 with route middleware:

```ts [middleware/filter-noindex.global.ts]
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:

<module-card className="w-1/2" slug="robots">



</module-card>

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  robots: {
    disallow: [
      '/*?ref=*',
      '/*?utm_*'
    ]
  }
})
```

Read more about [controlling crawlers](/learn-seo/nuxt/controlling-crawlers).

### Issue 4: 404 Handling

Return proper 404 status codes for missing content:

```vue
<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. If you're seeing unexpected 404s in Search Console, see the [indexing issues guide](/learn-seo/nuxt/launch-and-listen/indexing-issues) for diagnosis steps.

## Route Params Table

Common dynamic route patterns:

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

<tbody>
  <tr>
    <td>
      <code>
        pages/blog/[slug].vue
      </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>
        pages/products/[id].vue
      </code>
    </td>
    
    <td>
      <code>
        /products/123
      </code>
    </td>
    
    <td>
      <code>
        { id: '123' }
      </code>
    </td>
    
    <td>
      Product pages
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pages/docs/[category]/[page].vue
      </code>
    </td>
    
    <td>
      <code>
        /docs/api/methods
      </code>
    </td>
    
    <td>
      <code>
        { category: 'api', page: 'methods' }
      </code>
    </td>
    
    <td>
      Documentation
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pages/user/[id].vue
      </code>
    </td>
    
    <td>
      <code>
        /user/42
      </code>
    </td>
    
    <td>
      <code>
        { id: '42' }
      </code>
    </td>
    
    <td>
      User profiles
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pages/[lang]/about.vue
      </code>
    </td>
    
    <td>
      <code>
        /en/about
      </code>
    </td>
    
    <td>
      <code>
        { lang: 'en' }
      </code>
    </td>
    
    <td>
      Internationalized routes
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pages/files/[...path].vue
      </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>

## Route Validation

Validate params with `definePageMeta()`:

```vue [pages/user/[id].vue]
<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.

## TypeScript Support

Type route params:

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

```vue
<script setup lang="ts">
// Nuxt auto-generates route types
const route = useRoute('blog-slug') // Type-safe params based on file structure
</script>
```

## 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".

Nuxt's SSR works by default. you should always see complete HTML in the source.
