---
title: "SEO-Friendly URLs in Vue"
description: "Vue Router gives you no free URLs. Get slugs, casing, and query params right by hand before duplicates hurt your rankings."
canonical_url: "https://nuxtseo.com/learn-seo/vue/routes-and-rendering/url-structure"
last_updated: "2026-07-16"
---

<key-takeaways>

- Vue Router requires manual route definitions; design your URL hierarchy intentionally
- 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

</key-takeaways>

URLs appear in search results before users click. `/blog/vue-seo-guide` tells users what to expect; `/p?id=847` doesn't. Search engines also use URLs to understand page hierarchy and relevance.

Vue Router gives you full control over URL structure, but that control comes with responsibility. There is no file based routing to generate clean URLs automatically; you define every route manually in your router config. This means you choose the slug patterns, parameter handling, and hierarchy from scratch using `createRouter()` and `createWebHistory()`.

## Quick Setup

```ts [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
    }
  ]
})
```

```vue [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](https://unhead.unjs.io/docs/vue/head/guides/get-started/installation).

## URL Formatting Rules

### Hyphens Over Underscores

[Google treats hyphens as word separators](https://developers.google.com/search/docs/crawling-indexing/url-structure). Underscores connect words into single terms.

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

**Vue Router implementation:**

```text
// ✅ 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](/learn-seo/vue/controlling-crawlers/duplicate-content) issues.

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

Use [`kebabCase` from scule](https://github.com/unjs/scule) to generate lowercase slugs.

### Keep URLs Short

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

**Length comparison:**

<table>
<thead>
  <tr>
    <th>
      URL
    </th>
    
    <th>
      Length
    </th>
    
    <th>
      Result
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        /blog/vue-seo
      </code>
    </td>
    
    <td>
      14 chars
    </td>
    
    <td>
      ✅ Displays fully
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /blog/comprehensive-guide-to-vue-seo-optimization
      </code>
    </td>
    
    <td>
      50 chars
    </td>
    
    <td>
      ⚠️ Works but verbose
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /blog/a-comprehensive-guide-to-vue-server-side-rendering-seo-optimization-best-practices
      </code>
    </td>
    
    <td>
      98 chars
    </td>
    
    <td>
      ❌ Truncated in results
    </td>
  </tr>
</tbody>
</table>

**When longer URLs make sense:**

```text
✅ /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

Google's own guidance is that a [URL should describe the page's content clearly](https://developers.google.com/search/docs/crawling-indexing/url-structure) so users and crawlers can identify the concept at a glance. Front-load the important terms.

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

But don't sacrifice readability:

```text
// ✅ 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.

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

```ts
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 lets you republish old posts with new content without changing the URL, so links and rankings built up over time carry forward instead of starting over.

## Path Segments vs Query Parameters

![Path vs Query Parameter Decision](/images/learn-seo/vue/path-vs-query-decision.svg)

Search engines prefer path segments over query parameters: [static, keyword-based URLs have a higher likelihood of ranking](https://www.searchenginejournal.com/technical-seo/url-parameter-handling/) than parameter-based ones. Query parameters often cause duplicate content.

**Comparison:**

<table>
<thead>
  <tr>
    <th>
      Type
    </th>
    
    <th>
      Example
    </th>
    
    <th>
      SEO Impact
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Path segments
    </td>
    
    <td>
      <code>
        /products/phones/iphone-15
      </code>
    </td>
    
    <td>
      ✅ Clean, indexed, ranks well
    </td>
  </tr>
  
  <tr>
    <td>
      Query parameters
    </td>
    
    <td>
      <code>
        /products?category=phones&id=15
      </code>
    </td>
    
    <td>
      ⚠️ Duplicate content risk
    </td>
  </tr>
  
  <tr>
    <td>
      Mixed
    </td>
    
    <td>
      <code>
        /products/phones?sort=price
      </code>
    </td>
    
    <td>
      ✅ Path for content, query for filters
    </td>
  </tr>
</tbody>
</table>

**Problems with query parameters:**

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

Five URLs, same content. Google [spends crawl time on the duplicates instead of new or updated pages](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls). See [Query Parameters](/learn-seo/vue/routes-and-rendering/query-parameters) for handling strategies.

### Vue Router Path Segments

Use dynamic segments for content that should be indexed:

```ts [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`

Use query parameters for sorting, filtering, and pagination instead. Set canonical URLs to consolidate these variations. See the [Query Parameters guide](/learn-seo/vue/routes-and-rendering/query-parameters) for implementation details including canonical composables and parameter ordering.

## Dynamic Routes

[Vue Router's dynamic routes](https://router.vuejs.org/guide/essentials/dynamic-matching.html) create SEO-friendly URLs. Use descriptive slugs rather than database IDs: `/products/laptop-pro-15` ranks better than `/products/84792`.

### Basic Dynamic Segments

```ts [router.ts]
const routes = [
  {
    path: '/blog/:slug',
    component: BlogPost,
    props: true
  }
]
```

```vue [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

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

Generates hierarchical URLs:

- `/products/electronics/laptop`
- `/products/clothing/jacket`

```vue [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

```ts [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:

```ts
const route = useRoute()

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

## Slug Generation

Use [`kebabCase` from scule](https://github.com/unjs/scule):

```ts
import { kebabCase } from 'scule'

kebabCase('Vue Router Guide') // "vue-router-guide"
kebabCase('50% Off Sale!') // "50-off-sale"
```

For international content, [Google supports UTF-8 URLs](https://developers.google.com/search/docs/crawling-indexing/url-structure) but they must be percent-encoded when sent over HTTP. Consider whether your audience expects localized slugs or ASCII-only.

## URL Hierarchy and Internal Linking

URL structure defines your site's hierarchy, and that hierarchy directly affects how [internal links](/learn-seo/vue/routes-and-rendering/internal-linking) distribute authority. Flat structures (fewer path segments) make pages easier to crawl and link to. Deep nesting (4+ levels) signals low importance to Google.

<table>
<thead>
  <tr>
    <th>
      Structure
    </th>
    
    <th>
      Crawl Depth
    </th>
    
    <th>
      SEO Impact
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        /features/color-extraction
      </code>
    </td>
    
    <td>
      2 levels
    </td>
    
    <td>
      Good, easy to reach
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        /docs/v2/api/reference/methods/extract
      </code>
    </td>
    
    <td>
      6 levels
    </td>
    
    <td>
      Poor, buried content
    </td>
  </tr>
</tbody>
</table>

Keep important pages within 3 clicks of the homepage. Use hub pages (like `/features`) that link to all child pages, creating a clear topic cluster.

When generating pages at scale, use URL patterns that include target keywords:

```ts [router.ts]
const routes = [
  { path: '/compare/:slug', component: ComparePage },
  { path: '/alternatives/:slug', component: AlternativesPage },
  { path: '/features/:slug', component: FeaturePage },
]
```

Each pattern targets a different keyword cluster. Keep slugs descriptive: `/compare/pocketui-vs-css-peeper` rather than `/compare/1`, so both users and crawlers know what the page compares before they open it.

## Server Configuration

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

<code-group>

```nginx [Nginx]
location / {
  try_files $uri $uri/ /index.html;
}
```

```apache [Apache]
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^index\.html$ - [L]
  RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule . /index.html [L]
</IfModule>
```

```js [Express]
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)
```

```ts [H3]
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')
  }
})
```

</code-group>

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

## Testing URL Structure

**View in search results:**

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

**Check canonicalization:**

Use [Google Search Console URL Inspection](https://search.google.com/search-console) to verify:

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

## Checklist

<checklist id="vue-url-structure">

- URLs use hyphens, not underscores, to separate words
- All paths are lowercase
- Indexed content lives on path segments, not query parameters
- Filters and sorting use query parameters with a self-referencing canonical
- Evergreen content skips dates in the URL
- Slugs are descriptive (`laptop-pro-15`, not `84792`)
- The server serves `index.html` for every route (History mode)

</checklist>

File-based routing generates URLs automatically with canonical URLs, sitemaps, and route rules handled for you. [Learn more about URL structure in Nuxt →](/learn-seo/nuxt/routes-and-rendering)
