SEO-Friendly URLs in Vue

Create search-optimized URLs using Vue Router. Learn slug formatting, parameter handling, and route patterns that improve rankings.
Harlan WiltonHarlan Wilton8 mins read Published
What you'll learn
  • Use hyphens as word separators (not underscores)—Google treats hyphens as word breaks
  • Keep URLs under 60 characters and lowercase only
  • Use path segments for content, query parameters for filters and sorting

URLs appear in search results before users click. /blog/vue-seo-guide tells users what to expect. /p?id=847 doesn't. Search engines use URLs to understand page hierarchy and relevance. Well-structured URLs improve click-through rates by up to 15%.

For Vue applications requiring SSR, use Unhead for meta tags and configure Vue Router with proper slug patterns.

Quick Setup

Create SEO-friendly slugs from titles:

composables/useSeoSlug.ts
export function useSeoSlug(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with hyphens
    .replace(/^-+|-+$/g, '') // Trim leading/trailing hyphens
    .substring(0, 60) // Keep under 60 chars
}
router.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/blog/:slug',
      component: BlogPost
    },
    {
      path: '/products/:category/:slug',
      component: ProductPage
    }
  ]
})
pages/BlogPost.vue
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug

// Set canonical URL
useHead({
  link: [{
    rel: 'canonical',
    href: `https://mysite.com/blog/${slug}`
  }]
})
</script>

For Vue applications, you'll need to install Unhead manually.

URL Formatting Rules

Hyphens Over Underscores

Google treats hyphens as word separators. Underscores connect words into single terms.

✅ /performance-optimization    → "performance" + "optimization"
❌ /performance_optimization    → "performanceoptimization"

Google's Matt Cutts confirmed in 2011: "We use the words in a URL as a very lightweight factor... we can't easily segment at underscores."

Vue Router implementation:

// ✅ Good
{ path: '/learn-vue-router', component: Guide }

// ❌ Bad
{ path: '/learn_vue_router', component: Guide }

Lowercase Only

URLs are case-sensitive. /About, /about, and /ABOUT are different pages. This creates duplicate content issues.

✅ /about
✅ /products/phones
❌ /About
❌ /products/Phones

Enforce lowercase in your slug helper:

export function useSeoSlug(text: string): string {
  return text
    .toLowerCase() // ← Always lowercase
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
}

Keep URLs Short

URLs under 60 characters perform better in search results. Longer URLs get truncated with ellipsis.

Length comparison:

URLLengthResult
/blog/vue-seo14 chars✅ Displays fully
/blog/comprehensive-guide-to-vue-seo-optimization50 chars⚠️ Works but verbose
/blog/a-comprehensive-guide-to-vue-server-side-rendering-seo-optimization-best-practices98 chars❌ Truncated in results
export function useSeoSlug(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .substring(0, 60) // ← Limit to 60 chars
}

When longer URLs make sense:

✅ /docs/getting-started/installation-guide    (clear hierarchy)
✅ /blog/2025/fixing-vue-hydration-mismatch   (date + topic)
❌ /the-ultimate-comprehensive-complete-guide  (keyword stuffing)

Keywords Near the Start

Including keywords in URLs provides a lightweight ranking boost. Front-load important terms.

✅ /vue-router-seo-guide
✅ /seo/vue-best-practices
❌ /guides-and-tutorials-for-seo-in-vue-router

But don't sacrifice readability:

// ✅ Natural keyword placement
{ path: '/vue-seo/:topic', component: Guide }

// ❌ Keyword stuffing
{ path: '/vue-seo-guide-vue-router-seo-tutorial', component: Guide }

Avoid Dates (Usually)

Dates in URLs prevent content updates. /blog/2024/vue-guide becomes outdated when you refresh it in 2025.

❌ /blog/2024/vue-router-guide      (looks stale)
❌ /blog/2024/12/17/post-title      (prevents evergreen updates)
✅ /blog/vue-router-guide           (can be updated anytime)

Exception: Time-sensitive content like news, events, changelogs:

const routes = [
  // News/events - dates make sense
  { path: '/changelog/:year/:month/:slug', component: Changelog },
  { path: '/events/2025/:slug', component: Event },

  // Evergreen content - skip dates
  { path: '/blog/:slug', component: BlogPost },
  { path: '/guides/:slug', component: Guide }
]

Removing dates allows republishing old posts with new content without changing URLs—a strong SEO strategy.

Path Segments vs Query Parameters

Search engines prefer path segments over query parameters. Path segments are indexed and ranked. Query parameters often cause duplicate content.

Comparison:

TypeExampleSEO Impact
Path segments/products/phones/iphone-15✅ Clean, indexed, ranks well
Query parameters/products?category=phones&id=15⚠️ Duplicate content risk
Mixed/products/phones?sort=price✅ Path for content, query for filters

Problems with query parameters:

/products
/products?sort=price
/products?sort=date
/products?page=2
/products?sort=price&page=2

Five URLs, same content. Google sees duplicate content and wastes crawl budget.

Vue Router Path Segments

Use dynamic segments for content that should be indexed:

router.ts
const routes = [
  // ✅ Path segments for SEO
  {
    path: '/products/:category/:slug',
    component: Product
  },
  {
    path: '/blog/:year/:month/:slug',
    component: BlogPost
  },
  {
    path: '/docs/:section/:page',
    component: Documentation
  }
]

This generates clean URLs:

  • /products/phones/iphone-15
  • /blog/2025/12/vue-seo-guide
  • /docs/getting-started/installation

Query Parameters for Filters

Use query parameters for sorting, filtering, pagination—features that modify display without changing core content:

pages/Products.vue
<script setup lang="ts">
const route = useRoute()
const category = route.params.category
const sort = route.query.sort || 'popular'
const page = route.query.page || '1'

// Canonical URL excludes query params
useHead({
  link: [{
    rel: 'canonical',
    href: `https://mysite.com/products/${category}`
  }]
})
</script>

<template>
  <div>
    <!-- URL: /products/phones?sort=price&page=2 -->
    <!-- Canonical: /products/phones -->
  </div>
</template>

Set canonical URLs to consolidate ranking signals:

// Filter/sort variations point to base URL
useHead({
  link: [{
    rel: 'canonical',
    href: `https://mysite.com/products/${category}`
  }]
})

Dynamic Routes

Vue Router's dynamic routes create SEO-friendly URLs automatically.

Basic Dynamic Segments

router.ts
const routes = [
  {
    path: '/blog/:slug',
    component: BlogPost,
    props: true
  }
]
pages/BlogPost.vue
<script setup lang="ts">
const props = defineProps<{ slug: string }>()

// Fetch content based on slug
const { data: post } = await useFetch(`/api/posts/${props.slug}`)

// Set meta tags
useSeoMeta({
  title: post.value.title,
  description: post.value.excerpt,
  ogUrl: `https://mysite.com/blog/${props.slug}`
})

useHead({
  link: [{
    rel: 'canonical',
    href: `https://mysite.com/blog/${props.slug}`
  }]
})
</script>

Nested Dynamic Routes

router.ts
const routes = [
  {
    path: '/products/:category/:slug',
    component: Product,
    props: true
  }
]

Generates hierarchical URLs:

  • /products/electronics/laptop
  • /products/clothing/jacket
pages/Product.vue
<script setup lang="ts">
const props = defineProps<{
  category: string
  slug: string
}>()

const { data: product } = await useFetch(
  `/api/products/${props.category}/${props.slug}`
)

useSeoMeta({
  title: `${product.value.name} - ${props.category}`,
  description: product.value.description
})
</script>

Multiple Optional Parameters

router.ts
const routes = [
  {
    path: '/search/:query/:filters?',
    component: SearchResults
  }
]

Matches both:

  • /search/vue-router
  • /search/vue-router/recent

Important: Optional parameters create multiple URLs for similar content. Use canonical tags:

const route = useRoute()

useHead({
  link: [{
    rel: 'canonical',
    href: `https://mysite.com/search/${route.params.query}`
  }]
})

Slug Generation Patterns

From CMS Content

composables/useSlugFromTitle.ts
export function useSlugFromTitle(title: string): string {
  return title
    .toLowerCase()
    .trim()
    // Replace accented characters
    .normalize('NFD')
    .replace(/[\u0300-\u036F]/g, '')
    // Replace non-alphanumeric with hyphens
    .replace(/[^a-z0-9]+/g, '-')
    // Remove leading/trailing hyphens
    .replace(/^-+|-+$/g, '')
    // Limit length
    .substring(0, 60)
}
Example usage
const title = 'Vue Router: The Complete Guide (2025)'
const slug = useSlugFromTitle(title)
// Result: "vue-router-the-complete-guide-2025"

Preserving Non-ASCII Characters

For international content, use UTF-8 encoding in URLs:

composables/useInternationalSlug.ts
export function useInternationalSlug(text: string): string {
  return text
    .toLowerCase()
    .trim()
    // Keep Unicode letters and numbers
    .replace(/[^\p{L}\p{N}]+/gu, '-')
    .replace(/^-+|-+$/g, '')
    .substring(0, 60)
}
// Preserves non-ASCII
useInternationalSlug('Vue 路由指南')
// Result: "vue-路由指南"

// vs ASCII-only
useSlugFromTitle('Vue 路由指南')
// Result: "vue"

Handling Duplicates

Append numbers when slugs collide:

server/api/posts.ts
async function generateUniqueSlug(title: string): Promise<string> {
  const baseSlug = useSlugFromTitle(title)
  let slug = baseSlug
  let counter = 1

  while (await slugExists(slug)) {
    slug = `${baseSlug}-${counter}`
    counter++
  }

  return slug
}

Results:

  • First post: /blog/vue-router-guide
  • Second post with same title: /blog/vue-router-guide-2

Server Configuration

History Mode Requirements

Vue Router's History mode requires server configuration. All routes must serve index.html:

location / {
  try_files $uri $uri/ /index.html;
}

Without this configuration, direct URLs like /blog/vue-seo return 404 errors.

What NOT to Do

Don't use uppercase letters:

❌ /Blog/Vue-SEO-Guide
❌ /PRODUCTS/phones
✅ /blog/vue-seo-guide
✅ /products/phones

Don't expose internal IDs:

❌ /products/db-id-84792
❌ /posts?id=12345
✅ /products/laptop-pro-15
✅ /blog/vue-seo-guide

Don't create infinite parameter variations:

❌ /products?color=red&size=large&material=cotton&style=casual
✅ /products/red-cotton-casual-shirt

Don't change URLs without redirects:

// When changing URL structure, add redirects
const routes = [
  {
    path: '/old-blog-post',
    redirect: '/blog/new-post'
  },
  {
    path: '/blog/:slug',
    component: BlogPost
  }
]

But note: Client-side redirects don't send 301 status codes. Use server-side redirects for SEO.

Don't use special characters:

❌ /blog/vue&react-comparison
❌ /products/50%-off-sale!
✅ /blog/vue-react-comparison
✅ /products/50-percent-off-sale

Testing URL Structure

View in search results:

# Test how Google displays your URLs
site:yoursite.com "vue router"

Check canonicalization:

Use Google Search Console URL Inspection to verify:

  • User-declared canonical matches your intent
  • Google-selected canonical agrees with your preference
  • No conflicting signals from redirects or alternates

Validate slug generation:

tests/slugs.test.ts
import { describe, expect, it } from 'vitest'
import { useSlugFromTitle } from '~/composables/useSlugFromTitle'

describe('useSlugFromTitle', () => {
  it('converts title to lowercase slug', () => {
    expect(useSlugFromTitle('Vue Router Guide'))
      .toBe('vue-router-guide')
  })

  it('replaces spaces with hyphens', () => {
    expect(useSlugFromTitle('Learn Vue Router'))
      .toBe('learn-vue-router')
  })

  it('removes special characters', () => {
    expect(useSlugFromTitle('Vue & React!'))
      .toBe('vue-react')
  })

  it('limits length to 60 chars', () => {
    const longTitle = 'A'.repeat(100)
    expect(useSlugFromTitle(longTitle).length).toBe(60)
  })
})

Using Nuxt?

If you're using Nuxt, file-based routing generates URLs automatically. The Nuxt SEO module handles canonical URLs, sitemaps, and route rules.

Learn more about URL structure in Nuxt →