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%.
Nuxt's file-based routing generates SEO-friendly URLs automatically from your pages/ directory structure.
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
}
File-based routing in pages/:
pages/
blog/
[slug].vue → /blog/vue-seo-guide
products/
[category]/
[slug].vue → /products/phones/iphone-15
<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>
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."
Nuxt file structure:
pages/
learn-vue-router.vue ✅ Good
learn_vue_router.vue ❌ Bad
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:
pages/
vue-seo/
[topic].vue ✅ Natural keyword placement
vue-seo-guide-vue-router-seo-tutorial.vue ❌ Keyword stuffing
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:
pages/
changelog/
[year]/
[month]/
[slug].vue → /changelog/2025/12/new-feature
events/
2025/
[slug].vue → /events/2025/nuxt-conf
blog/
[slug].vue → /blog/vue-router-guide (evergreen)
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:
pages/
products/
[category]/
[slug].vue → /products/phones/iphone-15
blog/
[year]/
[month]/
[slug].vue → /blog/2025/12/nuxt-seo-guide
docs/
[section]/
[page].vue → /docs/getting-started/installation
Use 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}`
}]
})
Nuxt's file-based routing creates SEO-friendly URLs automatically.
pages/
blog/
[slug].vue → /blog/:slug
<script setup lang="ts">
const route = useRoute()
const slug = route.params.slug
// Fetch content based on slug
const { data: post } = await useFetch(`/api/posts/${slug}`)
// Set meta tags
useSeoMeta({
title: post.value.title,
description: post.value.excerpt,
ogUrl: `https://mysite.com/blog/${slug}`
})
useHead({
link: [{
rel: 'canonical',
href: `https://mysite.com/blog/${slug}`
}]
})
</script>
pages/
products/
[category]/
[slug].vue → /products/:category/:slug
Generates hierarchical URLs:
/products/electronics/laptop/products/clothing/jacket<script setup lang="ts">
const route = useRoute()
const category = route.params.category
const slug = route.params.slug
const { data: product } = await useFetch(
`/api/products/${category}/${slug}`
)
useSeoMeta({
title: `${product.value.name} - ${category}`,
description: product.value.description
})
</script>
pages/
search/
[...params].vue → /search/:params*
Matches:
/search/vue-router/search/vue-router/recent/search/vue-router/recent/2025Important: Catch-all routes create multiple URLs for similar content. Use canonical tags:
<script setup lang="ts">
const route = useRoute()
const query = Array.isArray(route.params.params)
? route.params.params[0]
: route.params.params
useHead({
link: [{
rel: 'canonical',
href: `https://mysite.com/search/${query}`
}]
})
</script>
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"
Composables in composables/ are auto-imported in Nuxt.
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-2Don'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:
export default defineNuxtConfig({
routeRules: {
'/old-blog-post': { redirect: '/blog/new-post' },
'/old-path/**': { redirect: '/new-path/**' }
}
})
These are server-side redirects that send proper 301 status codes. Learn more about redirects.
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)
})
})