Setting canonical URLs in Vue 3 composition API

Problem Statement

Client-side rendered (CSR) Vue 3 applications bypass traditional server-side meta injection. When Vue Router executes pushState navigation, the URL bar updates, but the DOM does not reload. Search engine crawlers initially parse the static index.html and only execute JavaScript after the hydration phase. If the <link rel="canonical"> tag remains static or updates asynchronously, Googlebot indexes the base URL for all route variations. This creates duplicate content clusters, splits link equity, and triggers parameter-based indexing bloat. For foundational head management patterns in SPAs, consult Vue Router Dynamic Meta Tags Setup. Without a deterministic, synchronous canonical injection strategy, dynamic routes will compete against each other in SERPs, degrading crawl budget efficiency and organic visibility.

Step-by-Step Fix

  1. Initialize History Mode: Configure Vue Router 4 with createWebHistory() to generate clean, slash-delimited paths that match canonical targets.
  2. Isolate Meta Logic: Extract canonical management into a dedicated useCanonical composable to prevent prop-drilling and maintain component purity.
  3. Reactive Route Watching: Implement a watch listener on router.currentRoute.value.path that triggers on every client-side transition.
  4. Absolute URL Construction: Concatenate window.location.origin with the normalized route path. Relative canonicals are ignored by major crawlers.
  5. Synchronous DOM Mutation: Query the existing <link rel="canonical"> element and update its href attribute directly. Avoid document.createElement on each navigation to prevent duplicate tag injection.
  6. Lifecycle Cleanup: Attach an onUnmounted hook to stop the watcher. This prevents memory leaks during hot-module replacement (HMR) and route teardown. Crawl/Index Impact: Synchronous DOM updates guarantee that the canonical tag is present before the JavaScript execution timeout expires. This aligns with Google’s rendering pipeline and ensures self-referencing canonicals are indexed correctly.

Validation

  • DevTools Elements Inspection: Navigate between dynamic routes in Chrome. Verify <link rel="canonical"> updates instantly without page reload.
  • MutationObserver Console Test: Execute new MutationObserver(m => console.log(m[0].target.href)).observe(document.querySelector('link[rel="canonical"]'), { attributes: true }) in the console. Confirms reactive DOM updates fire on route change.
  • Headless Fetch Simulation: Run npx puppeteer -e "const p = await browser.newPage(); await p.goto('https://example.com/products/123'); console.log(await p.$eval('link[rel=canonical]', el => el.href));" to replicate Googlebot’s JS execution environment.
  • GSC URL Inspection API: Submit the dynamic route. Pass criteria: User-declared canonical matches the rendered href exactly. Reject if Google-selected canonical differs.
  • Lighthouse SEO Audit: Run lighthouse --only-categories=seo. Pass criteria: 0 “Duplicate canonical” warnings, 100/100 SEO score for meta tags. Crawl/Index Impact: Headless validation confirms the tag survives hydration and matches the indexing pipeline. GSC verification proves Google respects the injected value over heuristic selection.

Code/Config

src/composables/useCanonical.ts

import { watch, onUnmounted } from 'vue';
import { useRoute } from 'vue-router';

export function useCanonical() {
 const route = useRoute();
 
 // Strip tracking parameters, normalize trailing slashes, enforce lowercase
 const normalizeUrl = (path: string) => {
 const cleanPath = path.replace(/\/+$/, '').toLowerCase();
 return `${window.location.origin}${cleanPath}`;
 };

 const canonicalLink = document.querySelector<HTMLLinkElement>('link[rel="canonical"]');

 const stopWatcher = watch(
 () => route.path,
 (newPath) => {
 if (!canonicalLink) return;
 canonicalLink.href = normalizeUrl(newPath);
 },
 { immediate: true }
 );

 onUnmounted(() => {
 stopWatcher();
 });
}

src/router/index.ts

import { createRouter, createWebHistory } from 'vue-router';
import { useCanonical } from '@/composables/useCanonical';

const router = createRouter({
 history: createWebHistory(import.meta.env.BASE_URL),
 routes: [
 { path: '/products/:id', component: () => import('@/views/ProductView.vue') }
 ]
});

// Global navigation guard ensures canonical updates on every route transition
router.afterEach(() => {
 useCanonical();
});

export default router;

index.html

<head>
 <!-- Static fallback for initial SSR/crawler parse -->
 <link rel="canonical" href="https://yourdomain.com/" />
</head>

Architecture Note: This implementation aligns with broader Framework-Specific SEO Implementations standards by decoupling SEO logic from UI components and relying on deterministic DOM mutations. The afterEach guard guarantees execution regardless of route depth, while the normalizeUrl function prevents query-string duplication and case-sensitivity indexing errors.

FAQ

Does Vue 3 Composition API automatically handle canonical tags? No. Vue 3 does not inject or manage canonical tags by default. You must implement a reactive composable, router guard, or head management plugin to update the DOM synchronously on route changes.

How do I prevent duplicate canonical tags during client-side navigation? Declare a single <link rel="canonical"> element in index.html and update its href attribute reactively via a watch on the router. Avoid document.createElement inside navigation hooks, as it appends duplicate tags that confuse crawlers.

Should canonical URLs include query strings in Vue SPAs? Generally, no. Strip tracking parameters (UTMs, session IDs, analytics tokens) and only preserve SEO-relevant query strings. Including transient parameters creates self-referencing duplicate content issues and wastes crawl budget.