Dynamic Routes in Nuxt for SEO

How to use Nuxt's file-based dynamic routes, set per-route meta tags, and avoid duplicate content issues with URL parameters.
Harlan WiltonHarlan Wilton10 mins read Published

Dynamic routes generate clean URLs from parameters. /blog/vue-seo-guide instead of /blog?id=123. Search engines prefer semantic paths over query parameters.

Nuxt's file-based routing handles this automatically. Create pages/blog/[slug].vue and Nuxt generates the route. Set per-route meta tags with useSeoMeta() 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

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

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:

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:

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:

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:

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

Generates: /docs/getting-started/installation

Access all params:

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:

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:

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

Catch-All Routes

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

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

Or nested catch-all:

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

Access the full path:

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:

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:

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

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

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

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

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

SEO Utils v7.0.19
1.4M
119
SEO utilities to improve your Nuxt sites discoverability and shareability.

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 { 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:

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

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:

Robots v5.6.7
7.6M
499
Tame the robots crawling and indexing your site with ease.
nuxt.config.ts
export default defineNuxtConfig({
  robots: {
    disallow: [
      '/*?ref=*',
      '/*?utm_*'
    ]
  }
})

Read more about controlling crawlers.

Issue 4: 404 Handling

Return proper 404 status codes for missing content:

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

Route Params Table

Common dynamic route patterns:

File PatternMatchesParamsUse Case
pages/blog/[slug].vue/blog/vue-guide{ slug: 'vue-guide' }Blog posts, articles
pages/products/[id].vue/products/123{ id: '123' }Product pages
pages/docs/[category]/[page].vue/docs/api/methods{ category: 'api', page: 'methods' }Documentation
pages/user/[id].vue/user/42{ id: '42' }User profiles
pages/[lang]/about.vue/en/about{ lang: 'en' }Internationalized routes
pages/files/[...path].vue/files/docs/api.md{ path: ['docs', 'api.md'] }Nested file paths

Route Validation

Validate params with definePageMeta():

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:

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

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