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.
Create SEO-friendly slugs from titles:
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
}
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/blog/:slug',
component: BlogPost
},
{
path: '/products/:category/:slug',
component: ProductPage
}
]
})
<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.
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 }
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, '')
}
URLs under 60 characters perform better in search results. Longer URLs get truncated with ellipsis.
Length comparison:
| URL | Length | Result |
|---|---|---|
/blog/vue-seo | 14 chars | ✅ Displays fully |
/blog/comprehensive-guide-to-vue-seo-optimization | 50 chars | ⚠️ Works but verbose |
/blog/a-comprehensive-guide-to-vue-server-side-rendering-seo-optimization-best-practices | 98 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)
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 }
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.
Search engines prefer path segments over query parameters. Path segments are indexed and ranked. Query parameters often cause duplicate content.
Comparison:
| Type | Example | SEO 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.
Use dynamic segments for content that should be indexed:
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/installationUse query parameters for sorting, filtering, pagination—features that modify display without changing core content:
<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}`
}]
})
Vue Router's dynamic routes create SEO-friendly URLs automatically.
const routes = [
{
path: '/blog/:slug',
component: BlogPost,
props: true
}
]
<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>
const routes = [
{
path: '/products/:category/:slug',
component: Product,
props: true
}
]
Generates hierarchical URLs:
/products/electronics/laptop/products/clothing/jacket<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>
const routes = [
{
path: '/search/:query/:filters?',
component: SearchResults
}
]
Matches both:
/search/vue-router/search/vue-router/recentImportant: 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}`
}]
})
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)
}
const title = 'Vue Router: The Complete Guide (2025)'
const slug = useSlugFromTitle(title)
// Result: "vue-router-the-complete-guide-2025"
For international content, use UTF-8 encoding in URLs:
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"
Append numbers when slugs collide:
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:
/blog/vue-router-guide/blog/vue-router-guide-2Vue Router's History mode requires server configuration. All routes must serve index.html:
location / {
try_files $uri $uri/ /index.html;
}
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import express from 'express'
const app = express()
const __dirname = dirname(fileURLToPath(import.meta.url))
// Serve static files
app.use(express.static(join(__dirname, 'dist')))
// All routes return index.html
app.get('*', (req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'))
})
app.listen(3000)
import { join } from 'node:path'
import { defineEventHandler, readFile, sendRedirect } from 'h3'
export default defineEventHandler(async (event) => {
const path = event.node.req.url
// Try to serve static file
try {
const file = await readFile(join('./dist', path))
return file
}
catch {
// Fall back to index.html for client-side routing
return await readFile('./dist/index.html')
}
})
Without this configuration, direct URLs like /blog/vue-seo return 404 errors.
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
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:
Validate slug generation:
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)
})
})
If you're using Nuxt, file-based routing generates URLs automatically. The Nuxt SEO module handles canonical URLs, sitemaps, and route rules.