Duplicate Content SEO in Vue

Duplicate content wastes crawl budget and splits ranking signals. Here's how to find and fix it with canonical tags, redirects, and parameter handling.
Harlan WiltonHarlan Wilton15 mins read Published
What you'll learn
  • 67.6% of websites have duplicate content issues—it dilutes ranking signals
  • Use canonical tags or 301 redirects to consolidate duplicates
  • Query parameters are the most common source of duplicate content

67.6% of websites have duplicate content issues. Same content at different URLs splits ranking signals and wastes crawl budget. Google picks which version to show—often not the one you want.

Google doesn't penalize duplicate content unless you're deliberately scraping other sites. But it hurts SEO by diluting link equity across multiple URLs and confusing search engines about which page to rank.

Common Causes

URL Variations

www vs non-www

www.mysite.com and mysite.com are treated as separate sites. Choose one, redirect the other.

HTTP vs HTTPS

http://mysite.com and https://mysite.com create duplicates. Always redirect HTTP to HTTPS.

Trailing slashes

/products and /products/ are different URLs. Pick one format site-wide.

import express from 'express'

const app = express()

app.use((req, res, next) => {
  const host = req.get('host')

  // Force non-www
  if (host.startsWith('www.')) {
    return res.redirect(301, `https://${host.slice(4)}${req.path}`)
  }

  next()
})

Query Parameters

URL parameters create exponential duplicates. Three filters generate 8 combinations. Add sorting and pagination—hundreds of URLs.

/products
/products?color=red
/products?color=red&size=large
/products?color=red&size=large&sort=price
/products?color=red&size=large&sort=price&page=2

Fix: Canonical tags

pages/Products.vue
<script setup lang="ts">
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'

const route = useRoute()

useHead({
  link: [{
    rel: 'canonical',
    // Always point to base URL, ignore parameters
    href: 'https://mysite.com/products'
  }]
})
</script>

Or block filtered pages from indexing:

<script setup lang="ts">
import { useSeoMeta } from '@unhead/vue'

useSeoMeta({
  robots: route.query.filter ? 'noindex, follow' : 'index, follow'
})
</script>

Parameter Order

?sort=price&filter=red and ?filter=red&sort=price are identical content, different URLs.

Fix: Enforce consistent parameter order

composables/useCanonicalParams.ts
export function useCanonicalParams(params: Record<string, string>) {
  const siteUrl = import.meta.env.VITE_SITE_URL
  const route = useRoute()

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

  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}${route.path}?${queryString}`
        : `${siteUrl}${route.path}`
    }]
  }
}

Tracking Parameters

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

Fix: Strip from canonical

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

  const trackingParams = [
    'utm_source',
    'utm_medium',
    'utm_campaign',
    'utm_term',
    'utm_content',
    'fbclid',
    'gclid',
    'msclkid',
    'mc_cid',
    'mc_eid',
    '_ga',
    'ref'
  ]

  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}`
    }]
  }
}

Better: Redirect tracking params at the server level for proper 301 status codes.

Pagination

Each paginated page has unique content. Use self-referencing canonicals—don't point page 2 to page 1.

pages/Blog.vue
<script setup lang="ts">
const route = useRoute()
const page = route.query.page || '1'

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

Printer-friendly URLs (/article?print=true) and mobile subdomains (m.mysite.com) create duplicates.

Fix: Canonical to desktop version

pages/Article.vue
<script setup lang="ts">
const route = useRoute()

useHead({
  link: [{
    rel: 'canonical',
    // Always point to main URL
    href: 'https://mysite.com/article'
  }]
})
</script>

For print, use CSS @media print instead of separate URLs.

Session IDs and Click Tracking

Session IDs in URLs create infinite variations.

/products?sessionid=abc123
/products?sessionid=xyz789
/products?sessionid=def456

Fix: Don't put session IDs in URLs. Use cookies. If unavoidable, block with robots.txt:

public/robots.txt
User-agent: *
Disallow: /*?sessionid=
Disallow: /*&sessionid=
Disallow: /*?sid=
Disallow: /*&sid=

Finding Duplicate Content

Google Search Console

Use the Page Indexing report to identify duplicates:

  1. Open Search Console
  2. Go to "Indexing" → "Pages"
  3. Look for:
    • "Duplicate, Google chose different canonical than user"
    • "Duplicate without user-selected canonical"
    • "Alternate page with proper canonical tag"

Click each category to see affected URLs. If Google chose a different canonical than you specified, conflicting signals exist.

Using URL Inspection:

  1. Enter any URL
  2. Check "User-declared canonical" vs "Google-selected canonical"
  3. If they differ, Google found stronger signals pointing to a different URL

Screaming Frog

Screaming Frog detects exact and near-duplicate content:

Exact duplicates — Pages with identical HTML (MD5 hash match)

Near duplicates — Pages with 90%+ similarity (minhash algorithm)

Setup:

  1. Enable near duplicates: Config > Content > Duplicates
  2. Crawl your site
  3. Go to "Content" tab
  4. Filter by "Exact Duplicates" or "Near Duplicates"

Check these columns:

  • Closest Similarity Match — Percentage match to most similar page
  • No. Near Duplicates — Count of similar pages
  • Hash — MD5 hash for exact duplicate detection

Screaming Frog auto-excludes nav and footer elements to focus on main content. Adjust threshold if needed (default 90%).

Use Google site search to find duplicates manually:

site:mysite.com "exact title text"

If multiple URLs appear with the same title, you have duplicates.

Siteliner and Copyscape

Siteliner — Free tool that crawls up to 250 pages, shows duplicate content percentage

Copyscape — Detects external duplicate content (other sites copying you)

Both useful for content audits but don't replace Search Console or Screaming Frog for technical SEO.

Canonical vs 301 Redirect

When to UseCanonical Tag301 Redirect
Need both URLs live✅ Yes❌ No
User should see one URL❌ No✅ Yes
Products in multiple categories✅ Yes❌ No
Old page no longer needed❌ No✅ Yes
UTM tracking parameters✅ Yes❌ No
www vs non-www❌ No✅ Yes
HTTP vs HTTPS❌ No✅ Yes
Moved/renamed pages❌ No✅ Yes

Canonical tags are hints, not directives. Google may ignore them. Both versions remain accessible. Use for duplicates you need (tracking params, multiple category paths).

301 redirects are permanent. Users see the redirect target. Pass the same link equity as canonicals but remove the duplicate from the index. Use for outdated or unnecessary URLs.

Don't combine: Using both canonical tag and 301 redirect on the same page sends conflicting signals. Pick one.

Decision Tree

Examples:

  • http://mysite.comhttps://mysite.com301 redirect
  • www.mysite.commysite.com301 redirect
  • /products?utm_source=twitter/productsCanonical tag
  • /products/shoes and /sale/shoes (same product) — Canonical tag (one canonical, one alternate)
  • /products?filter=redNoindex + canonical to base URL
  • /old-page/new-page301 redirect

Common Mistakes

Mistake 1: Canonicalizing all paginated pages to page 1

<!-- ❌ Wrong - hides pages 2+ from search -->
<script setup>
useHead({
  link: [{ rel: 'canonical', href: 'https://mysite.com/blog' }]
})
</script>

Each paginated page should reference itself.

Mistake 2: Using relative canonical URLs

<!-- ❌ Wrong - must be absolute -->
<link rel="canonical" href="/products/phone">

<!-- ✅ Correct -->
<link rel="canonical" href="https://mysite.com/products/phone">

Google requires absolute URLs.

Mistake 3: Combining canonical with noindex

<!-- ❌ Conflicting signals -->
<script setup>
useHead({
  link: [{ rel: 'canonical', href: 'https://mysite.com/page' }]
})
useSeoMeta({
  robots: 'noindex, follow'
})
</script>

Canonical says "this is a duplicate of X." Noindex says "don't index this." Pick one.

Mistake 4: Canonical chains

Page A → canonical → Page B → canonical → Page C

Google may ignore chained canonicals. Canonical directly to the final target.

Mistake 5: Client-side canonicals in SPAs

Googlebot doesn't execute JavaScript fast enough. Server-render canonical tags or use SSR.

Testing

1. View page source (not DevTools)

curl https://mysite.com/products?sort=price | grep canonical

Should return:

<link rel="canonical" href="https://mysite.com/products">

2. Google Search Console URL Inspection

  1. Enter URL with parameters
  2. Check "User-declared canonical"
  3. Compare to "Google-selected canonical"
  4. Investigate if they differ

3. Check for canonicalization conflicts

  • Multiple rel="canonical" tags on same page
  • Canonical in <head> vs HTTP header
  • Canonical points to redirect
  • Canonical points to noindexed page
  • Canonical URL returns 4xx/5xx status

4. Test redirect chains

curl -I https://mysite.com/old-url

Should show one 301 redirect, not a chain.

Preventing Duplicate Content

Configure Vue Router

Use consistent trailing slash handling:

router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [/* ... */],
  strict: true // Treat /page and /page/ as different
})

// Enforce trailing slashes
router.beforeEach((to, from, next) => {
  if (!to.path.endsWith('/') && to.path !== '/') {
    next({ path: `${to.path}/`, query: to.query })
  }
  else {
    next()
  }
})

Or redirect at the server level for proper 301 status codes.

Validate Parameter Values

Prevent infinite URL variations by whitelisting allowed parameter values:

const allowedSortValues = ['price', 'name', 'date', 'rating']
const sort = route.query.sort

if (sort && !allowedSortValues.includes(sort)) {
  // Redirect to base URL or default sort
  router.replace({ query: { ...route.query, sort: undefined } })
}

Block Low-Value Pages

Use robots.txt to block search results, filtered pages, and admin sections:

public/robots.txt
User-agent: *

# Block search results
Disallow: /search?
Disallow: /*?q=
Disallow: /*?query=

# Block filters
Disallow: /*?filter=
Disallow: /*&filter=

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

# Block session IDs
Disallow: /*?sessionid=
Disallow: /*?sid=

Using Nuxt?

If you're using Nuxt, check out Nuxt SEO which handles canonical URLs automatically through site config and route rules.

Learn more about duplicate content in Nuxt →