Query Parameters and SEO in Vue · Nuxt SEO

-
-
-
-

[1.4K](https://github.com/harlan-zw/nuxt-seo)

[Nuxt SEO on GitHub](https://github.com/harlan-zw/nuxt-seo)

Learn SEO

Master search optimization

Nuxt

 Vue

-
-
-
-
-
-
-

-
-
-
-
-
-
-

-
-
-

-
-
-
-
-
-
-
-
-
-
-

-
-
-

-
-
-
-
-
-
-
-
-

1.
2.
3.
4.
5.

# Query Parameters and SEO in Vue

Query parameters create duplicate content and waste crawl budget. Here's how to handle filters, sorting, and tracking params in Vue Router.

[![Harlan Wilton](https://avatars.githubusercontent.com/u/5326365?v=4)Harlan Wilton](https://x.com/harlan-zw)12 mins read Published Dec 17, 2025

What you'll learn

- Filter parameters should be noindexed or canonical to base URL
- Strip tracking params (utm_, fbclid, gclid) from canonical URLs
- Move important filters to path segments for better SEO value

For deciding whether content belongs in a path segment or query parameter, see

.

Query parameters (`?sort=price&filter=red`) create duplicate content. In Vue, you access these through `useRoute().query` from `vue-router`, and managing their SEO impact requires manual canonical setup with `@unhead/vue`. The URL `/products?sort=price` and `/products?sort=name` show the same products in different orders, but search engines treat them as separate pages.

With filters, sorting, and pagination, a single page can generate hundreds of URL variations. Each variation wastes crawl budget and dilutes ranking signals across duplicates.

## [Quick Setup](#quick-setup)

Handle query parameters with canonical URLs to tell search engines which version to index:

pages/Products.vue

```
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'

const route = useRoute()
const { sort, filter, page } = route.query

useHead({
  link: [{
    rel: 'canonical',
    // Remove filter and page, keep sort
    href: sort
      ? \`https://mysite.com/products?sort=${sort}\`
      : 'https://mysite.com/products'
  }]
})
```

pages/Products.vue - Block All Parameters

```
useHead({
  link: [{
    rel: 'canonical',
    // Always canonical to base URL
    href: 'https://mysite.com/products'
  }]
})
```

pages/Products.vue - Block from Indexing

```
import { useSeoMeta } from '@unhead/vue'

// Use noindex for filtered/sorted views
useSeoMeta({
  robots: computed(() =>
    route.query.filter || route.query.page
      ? 'noindex, follow'
      : 'index, follow'
  )
})
```

For Vue applications, you'll need to [install Unhead manually](https://unhead.unjs.io/guide/getting-started/installation).

## [Common Parameter Types](#common-parameter-types)

Different query parameters need different handling:

| Parameter Type | Examples | SEO Treatment | Why |
| --- | --- | --- | --- |
| **Filters** | `?color=red&size=large` | Canonical to base or noindex | Creates duplicates, thin content |
| **Sorting** | `?sort=price` | Include in canonical | Changes value, users link to sorted views |
| **Pagination** | `?page=2` | Self-referencing canonical | Each page has unique content |
| **Tracking** | `?utm_source=twitter` | Strip from canonical | No content value, analytics only |
| **Search** | `?q=shoes` | Depends on results | Index if unique results, noindex if duplicates |
| **Sessions** | `?sessionid=abc` | Canonical to base | Creates infinite URLs |

## [Filter Parameters](#filter-parameters)

Filters create exponential URL variations. Three color filters generate 8 combinations (red, blue, green, red+blue, red+green, blue+green, red+blue+green, none).

### [Block Filtered Pages](#block-filtered-pages)

pages/Products.vue

```
const route = useRoute()
const hasFilters = computed(() =>
  route.query.color || route.query.size || route.query.brand
)

useHead({
  link: [{
    rel: 'canonical',
    href: 'https://mysite.com/products'
  }]
})

useSeoMeta({
  robots: hasFilters.value ? 'noindex, follow' : 'index, follow'
})
```

### [Move Important Filters to Path](#move-important-filters-to-path)

For SEO-valuable filters (categories, main attributes), use route paths instead of query params:

❌ Query Parameters

✅ Path Segments

```
// /products?category=shoes
const routes = [{
  path: '/products',
  component: Products
}]
```

```
// /products/shoes
const routes = [{
  path: '/products/:category',
  component: Products
}]
```

## [Sort Parameters](#sort-parameters)

Sorting changes presentation but not content. Users share sorted URLs ("cheapest laptops" links to `?sort=price`).

### [Include Sort in Canonical](#include-sort-in-canonical)

pages/Products.vue

```
const route = useRoute()
const allowedSortValues = ['price', 'name', 'date', 'rating']
const sort = computed(() =>
  allowedSortValues.includes(route.query.sort)
    ? route.query.sort
    : undefined
)

useHead({
  link: [{
    rel: 'canonical',
    href: sort.value
      ? \`https://mysite.com/products?sort=${sort.value}\`
      : 'https://mysite.com/products'
  }]
})
```

**Why validate sort values?** Prevents parameter manipulation creating infinite URLs (`?sort=abc`, `?sort=xyz`).

### [Block Sort from Indexing](#block-sort-from-indexing)

If sorted views don't add value (same content, different order), use noindex:

```
useSeoMeta({
  robots: route.query.sort ? 'noindex, follow' : 'index, follow'
})
```

## [Pagination Parameters](#pagination-parameters)

Each page in a sequence has unique content. [Google recommends self-referencing canonicals](https://developers.google.com/search/docs/specialty/ecommerce/pagination-and-incremental-page-loading). don't point page 2 to page 1. See the

 for full implementation details.

pages/Blog.vue

```
const route = useRoute()
const page = computed(() => route.query.page || '1')

useHead({
  link: [{
    rel: 'canonical',
    // Each page references itself
    href: page.value === '1'
      ? 'https://mysite.com/blog'
      : \`https://mysite.com/blog?page=${page.value}\`
  }]
})
```

### [Validate Page Numbers](#validate-page-numbers)

```
const page = computed(() => {
  const pageNum = Number.parseInt(route.query.page)
  return pageNum > 0 && pageNum <= maxPages ? pageNum : 1
})
```

Prevents crawlers requesting `?page=999999` and wasting server resources.

## [Tracking Parameters](#tracking-parameters)

Analytics parameters (utm_, fbclid, gclid) don't change content but create duplicate URLs.

### [Strip Tracking Params](#strip-tracking-params)

composables/useCanonicalUrl.ts

```
export function useCanonicalUrl(path: string) {
  const siteUrl = import.meta.env.VITE_SITE_URL
  const route = useRoute()

  // List of tracking params to ignore
  const trackingParams = [
    'utm_source',
    'utm_medium',
    'utm_campaign',
    'utm_term',
    'utm_content',
    'fbclid',
    'gclid',
    'msclkid',
    'mc_cid',
    'mc_eid',
    '_ga',
    'ref',
    'source'
  ]

  // Keep only non-tracking params
  const cleanParams = Object.fromEntries(
    Object.entries(route.query).filter(([key]) =>
      !trackingParams.includes(key)
    )
  )

  const queryString = new URLSearchParams(cleanParams).toString()

  return {
    link: [{
      rel: 'canonical',
      href: queryString
        ? \`${siteUrl}${path}?${queryString}\`
        : \`${siteUrl}${path}\`
    }]
  }
}
```

### [Server-Side Parameter Handling](#server-side-parameter-handling)

Redirect tracking parameters at the server level for proper 301 status codes:

Express

Vite

H3

```
import express from 'express'

const app = express()

app.use((req, res, next) => {
  const trackingParams = ['utm_source', 'fbclid', 'gclid']
  const hasTracking = trackingParams.some(param => req.query[param])

  if (hasTracking) {
    const cleanQuery = Object.fromEntries(
      Object.entries(req.query).filter(([key]) =>
        !trackingParams.includes(key)
      )
    )
    const queryString = new URLSearchParams(cleanQuery).toString()
    return res.redirect(301, \`${req.path}${queryString ? \`?${queryString}\` : ''}\`)
  }
  next()
})
```

```
// server.js for Vite SSR
import express from 'express'

const app = express()

app.use((req, res, next) => {
  const trackingParams = ['utm_source', 'fbclid', 'gclid']
  const hasTracking = trackingParams.some(param => req.query[param])

  if (hasTracking) {
    const cleanQuery = Object.fromEntries(
      Object.entries(req.query).filter(([key]) =>
        !trackingParams.includes(key)
      )
    )
    const queryString = new URLSearchParams(cleanQuery).toString()
    return res.redirect(301, \`${req.path}${queryString ? \`?${queryString}\` : ''}\`)
  }
  next()
})
```

```
import { defineEventHandler, getQuery, sendRedirect } from 'h3'

export default defineEventHandler((event) => {
  const query = getQuery(event)
  const trackingParams = ['utm_source', 'fbclid', 'gclid']
  const hasTracking = trackingParams.some(param => query[param])

  if (hasTracking) {
    const cleanQuery = Object.fromEntries(
      Object.entries(query).filter(([key]) =>
        !trackingParams.includes(key)
      )
    )
    const queryString = new URLSearchParams(cleanQuery).toString()
    return sendRedirect(event, \`${event.path}${queryString ? \`?${queryString}\` : ''}\`, 301)
  }
})
```

## [Parameter Order Consistency](#parameter-order-consistency)

`?sort=price&filter=red` and `?filter=red&sort=price` are identical content but different URLs. Enforce consistent parameter ordering:

composables/useCanonicalUrl.ts

```
// eslint-disable-next-line harlanzw/vue-no-faux-composables
export function useCanonicalUrl(path: string, params: Record<string, string>) {
  const siteUrl = import.meta.env.VITE_SITE_URL

  // Define parameter order
  const paramOrder = ['category', 'sort', 'filter', 'page']

  // Sort params by predefined order
  const orderedParams = Object.fromEntries(
    Object.entries(params)
      .sort(([a], [b]) => {
        const indexA = paramOrder.indexOf(a)
        const indexB = paramOrder.indexOf(b)
        if (indexA === -1)
          return 1
        if (indexB === -1)
          return -1
        return indexA - indexB
      })
  )

  const queryString = new URLSearchParams(orderedParams).toString()

  return {
    link: [{
      rel: 'canonical',
      href: queryString
        ? \`${siteUrl}${path}?${queryString}\`
        : \`${siteUrl}${path}\`
    }]
  }
}
```

### [Vue Router Navigation](#vue-router-navigation)

Force parameter order when updating query strings:

```
import { useRouter } from 'vue-router'

const router = useRouter()

function updateFilters(filters: Record<string, string>) {
  const paramOrder = ['category', 'sort', 'filter', 'page']

  const ordered = Object.fromEntries(
    Object.entries(filters)
      .sort(([a], [b]) => {
        const indexA = paramOrder.indexOf(a)
        const indexB = paramOrder.indexOf(b)
        return indexA - indexB
      })
  )

  navigateTo({
    query: ordered
  })
}
```

## [Search Parameters](#search-parameters)

Search queries create unique URLs for each search term. Treatment depends on result quality:

### [Block Thin Search Results](#block-thin-search-results)

pages/Search.vue

```
const route = useRoute()
const query = route.query.q
const results = await searchProducts(query)

useSeoMeta({
  robots: !query || results.length < 5
    ? 'noindex, follow'
    : 'index, follow'
})

useHead({
  link: [{
    rel: 'canonical',
    href: query
      ? \`https://mysite.com/search?q=${encodeURIComponent(query)}\`
      : 'https://mysite.com/search'
  }]
})
```

### [robots.txt for Search](#robotstxt-for-search)

Block crawlers from search entirely:

public/robots.txt

```
User-agent: *
Disallow: /search?

# Or use URL patterns
Disallow: /*?q=
Disallow: /*?query=
```

## [Testing Parameter Handling](#testing-parameter-handling)

### [Google Search Console](#google-search-console)

1. URL Inspection tool
2. Enter URL with parameters
3. Check "User-declared canonical" vs "Google-selected canonical"
4. Verify they match your preference

### [Manual Verification](#manual-verification)

```
# Check canonical in HTML
curl https://mysite.com/products?sort=price | grep canonical

# Should return:
# <link rel="canonical" href="https://mysite.com/products?sort=price">
```

### [Test Parameter Variations](#test-parameter-variations)

Create a test matrix:

| URL | Expected Canonical | Expected Robots |
| --- | --- | --- |
| `/products` | Self | index, follow |
| `/products?sort=price` | Self or base | Depends on strategy |
| `/products?filter=red` | Base URL | noindex, follow |
| `/products?utm_source=twitter` | Base URL | index, follow |
| `/products?page=2` | Self | index, follow |

## [robots.txt Parameter Blocking](#robotstxt-parameter-blocking)

Block specific parameters from crawling entirely:

public/robots.txt

```
User-agent: *

# Block all URLs with these params
Disallow: /*?sessionid=
Disallow: /*?sid=
Disallow: /*&sessionid=
Disallow: /*&sid=

# Block tracking params
Disallow: /*?utm_source=
Disallow: /*?fbclid=
Disallow: /*?gclid=

# Block filter combinations
Disallow: /*?filter=
Disallow: /*&filter=
```

[Google deprecated parameter handling in Search Console](https://developers.google.com/search/blog/2022/06/retiring-url-parameters-tool) in 2022. Use robots.txt or meta robots instead.

## [Common Mistakes](#common-mistakes)

**Using client-side canonicals for SPAs:** Googlebot doesn't execute JavaScript fast enough. Server-render canonical tags or use SSR.

**Indexing every parameter variation:** Creates thin content and wastes crawl budget. Pick one canonical version.

**Inconsistent parameter handling:** Some pages canonical to base, others to self. Be consistent site-wide.

**Ignoring tracking parameters:** Analytics params create duplicate URLs. Strip them from canonicals.

**Not validating parameter values:** Allows `?sort=anything` creating infinite URLs. Whitelist valid values.

## [Using Nuxt?](#using-nuxt)

If you're using Nuxt, it provides automatic canonical URL handling.

---

On this page

- [Quick Setup](#quick-setup)
- [Common Parameter Types](#common-parameter-types)
- [Filter Parameters](#filter-parameters)
- [Sort Parameters](#sort-parameters)
- [Pagination Parameters](#pagination-parameters)
- [Tracking Parameters](#tracking-parameters)
- [Parameter Order Consistency](#parameter-order-consistency)
- [Search Parameters](#search-parameters)
- [Testing Parameter Handling](#testing-parameter-handling)
- [robots.txt Parameter Blocking](#robotstxt-parameter-blocking)
- [Common Mistakes](#common-mistakes)
- [Using Nuxt?](#using-nuxt)

[GitHub](https://github.com/harlan-zw/nuxt-seo) [ Discord](https://discord.com/invite/275MBUBvgP)

###

-
-

Modules

-
-
-
-
-
-
-
-
-

###

-
-
-

###

Nuxt

-
-
-
-
-

Vue

-
-
-
-
-
-
-
-

###

-
-
-
-
-
-
-
-
-
-

Copyright © 2023-2026 Harlan Wilton - [MIT License](https://github.com/harlan-zw/nuxt-seo/blob/main/license) · [mdream](https://mdream.dev)