Query Parameters and SEO in Nuxt

Query parameters create duplicate content and waste crawl budget. Here's how to handle filters, sorting, and tracking params in Nuxt.
Harlan WiltonHarlan Wilton12 mins read Published

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.

Quick Setup

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

pages/products.vue
<script setup>
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'
  }]
})
</script>
pages/products.vue - Block All Parameters
<script setup>
useHead({
  link: [{
    rel: 'canonical',
    // Always canonical to base URL
    href: 'https://mysite.com/products'
  }]
})
</script>
pages/products.vue - Block from Indexing
<script setup>
const route = useRoute()

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

Common Parameter Types

Different query parameters need different handling:

Parameter TypeExamplesSEO TreatmentWhy
Filters?color=red&size=largeCanonical to base or noindexCreates duplicates, thin content
Sorting?sort=priceInclude in canonicalChanges value, users link to sorted views
Pagination?page=2Self-referencing canonicalEach page has unique content
Tracking?utm_source=twitterStrip from canonicalNo content value, analytics only
Search?q=shoesDepends on resultsIndex if unique results, noindex if duplicates
Sessions?sessionid=abcCanonical to baseCreates infinite URLs

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

pages/products.vue
<script setup>
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'
})
</script>

Move Important Filters to Path

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

// /products?category=shoes
// Not ideal for important categories

Sort Parameters

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

Include Sort in Canonical

pages/products.vue
<script setup>
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'
  }]
})
</script>

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

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

Each page in a sequence has unique content. Google recommends self-referencing canonicals—don't point page 2 to page 1.

pages/blog.vue
<script setup>
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}`
  }]
})
</script>

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

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

Strip Tracking Params

composables/useCanonicalUrl.ts
export function useCanonicalUrl(path: string) {
  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
        ? `${useSiteConfig().url}${path}?${queryString}`
        : `${useSiteConfig().url}${path}`
    }]
  }
}

Server-Side Parameter Handling

Nuxt handles tracking parameter redirects through route rules:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/**': {
      redirect: {
        // Strip tracking params via middleware
      }
    }
  }
})

Or use server middleware:

server/middleware/tracking-params.ts
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

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

composables/useCanonicalUrl.ts
export function useCanonicalUrl(path: string, params: Record<string, string>) {
  // 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
        ? `${useSiteConfig().url}${path}?${queryString}`
        : `${useSiteConfig().url}${path}`
    }]
  }
}

Force parameter order when updating query strings:

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 Parameters

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

Block Thin Search Results

pages/search.vue
<script setup>
const route = useRoute()
const query = route.query.q
const { data: results } = await useFetch('/api/search', {
  query: { q: query }
})

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

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

Block crawlers from search entirely:

public/robots.txt
User-agent: *
Disallow: /search?

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

Testing Parameter Handling

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

# 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

Create a test matrix:

URLExpected CanonicalExpected Robots
/productsSelfindex, follow
/products?sort=priceSelf or baseDepends on strategy
/products?filter=redBase URLnoindex, follow
/products?utm_source=twitterBase URLindex, follow
/products?page=2Selfindex, follow

robots.txt 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 in 2022. Use robots.txt or meta robots instead.

Common Mistakes

Using client-side canonicals: Nuxt renders canonical tags server-side by default, ensuring search engines see them immediately.

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.