SEO-Friendly URLs in Nuxt

Create search-optimized URLs using file-based routing. Learn slug formatting, parameter handling, and route patterns that improve rankings.
Harlan WiltonHarlan Wilton8 mins read Published

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.

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
}

File-based routing in pages/:

pages/
  blog/
    [slug].vue          → /blog/vue-seo-guide
  products/
    [category]/
      [slug].vue        → /products/phones/iphone-15
pages/blog/[slug].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>

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

Nuxt file structure:

pages/
  learn-vue-router.vue     ✅ Good
  learn_vue_router.vue     ❌ Bad

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:

pages/
  vue-seo/
    [topic].vue          ✅ Natural keyword placement
  vue-seo-guide-vue-router-seo-tutorial.vue  ❌ Keyword stuffing

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:

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.

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.

Nuxt Dynamic Routes

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

Query Parameters for Filters

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

pages/products/[category].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

Nuxt's file-based routing creates SEO-friendly URLs automatically.

Basic Dynamic Segments

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

Nested Dynamic Routes

pages/
  products/
    [category]/
      [slug].vue        → /products/:category/:slug

Generates hierarchical URLs:

  • /products/electronics/laptop
  • /products/clothing/jacket
pages/products/[category]/[slug].vue
<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>

Catch-All Routes

pages/
  search/
    [...params].vue     → /search/:params*

Matches:

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

Important: Catch-all routes create multiple URLs for similar content. Use canonical tags:

pages/search/[...params].vue
<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>

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"

Composables in composables/ are auto-imported in Nuxt.

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

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:

nuxt.config.ts
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

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)
  })
})