Query parameters (?sort=price&filter=red) create duplicate content. 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.
Handle query parameters with canonical URLs to tell search engines which version to index:
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'
}]
})
useHead({
link: [{
rel: 'canonical',
// Always canonical to base URL
href: 'https://mysite.com/products'
}]
})
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.
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 |
Filters create exponential URL variations. Three color filters generate 8 combinations (red, blue, green, red+blue, red+green, blue+green, red+blue+green, none).
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'
})
For SEO-valuable filters (categories, main attributes), use route paths instead of query params:
// /products?category=shoes
const routes = [{
path: '/products',
component: Products
}]
// /products/shoes
const routes = [{
path: '/products/:category',
component: Products
}]
Sorting changes presentation but not content. Users share sorted URLs ("cheapest laptops" links to ?sort=price).
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).
If sorted views don't add value (same content, different order), use noindex:
useSeoMeta({
robots: route.query.sort ? 'noindex, follow' : 'index, follow'
})
Each page in a sequence has unique content. Google recommends self-referencing canonicals—don't point page 2 to page 1.
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}`
}]
})
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.
Analytics parameters (utm_, fbclid, gclid) don't change content but create duplicate URLs.
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}`
}]
}
}
Redirect tracking parameters at the server level for proper 301 status codes:
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)
}
})
?sort=price&filter=red and ?filter=red&sort=price are identical content but different URLs. Enforce consistent parameter ordering:
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}`
}]
}
}
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
})
)
router.push({
query: ordered
})
}
Search queries create unique URLs for each search term. Treatment depends on result quality:
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'
}]
})
Block crawlers from search entirely:
User-agent: *
Disallow: /search?
# Or use URL patterns
Disallow: /*?q=
Disallow: /*?query=
# 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">
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 |
Block specific parameters from crawling entirely:
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 in 2022. Use robots.txt or meta robots instead.
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.
If you're using Nuxt, check out Nuxt SEO for automatic canonical URL handling and parameter management through site config.