Canonical Tags vs HTTP Link Headers in Headless

Both mechanisms declare the same thing, but only one of them works for a response with no HTML — and having both on the same URL is worse than having neither.

When to Use This Approach

Settle the mechanism when:

  • Your site serves indexable non-HTML responses: PDFs, RSS feeds, or generated documents.
  • Edge middleware and the framework both touch canonicals and nobody can say which one is authoritative.
  • Search Console reports a Google-selected canonical that differs from what your HTML declares.

One Declaration Per Response

The rule is simple to state and easy to violate accidentally: exactly one canonical declaration per response, from exactly one layer.

Which mechanism each response type should use Three response types — HTML page, PDF or feed, and a page carrying both declarations — mapped to the canonical mechanism that applies, with the third marked as an undefined outcome. HTML page has a head element link rel=canonical in the head owner: framework metadata layer PDF, feed, image no head to use Link: rel="canonical" header owner: edge middleware page with both tag and header undefined — treat as no canonical detect it, do not reason about it

Implementation Steps

Step 1: Decide by response type, not by convenience

// lib/canonical-mechanism.js
export function mechanismFor(contentType) {
  if (contentType.includes('text/html')) return 'meta-tag';
  if (/pdf|xml|json|csv|image\//.test(contentType)) return 'link-header';
  return 'none';
}

Validation: Any code path that sets a Link: rel="canonical" on a text/html response is a bug, regardless of what value it sets.

Step 2: Build every canonical from one origin constant

Most canonical conflicts are not disagreements about the path but about the host, the scheme, or the trailing slash.

// lib/canonical-url.js — the only place an absolute URL is constructed
export const ORIGIN = 'https://example.com';

export function canonicalUrl(pathname, search = '') {
  const path = pathname.endsWith('/') ? pathname : `${pathname}/`;
  return ORIGIN + path + search;
}

SEO impact: Removes the entire class of conflicts where two layers each produce a “correct” canonical that differ only in a trailing slash — the variant Google treats as a separate URL.

Validation: Grep the codebase for hard-coded https:// occurrences outside this module. Every hit is a future conflict.

Step 3: Emit the tag from the metadata layer

// app/blog/[slug]/page.jsx
import { canonicalUrl } from '@/lib/canonical-url';

export async function generateMetadata({ params }) {
  return {
    alternates: { canonical: canonicalUrl(`/blog/${params.slug}`) },
  };
}

SEO impact: A single owner means a reviewer can find every canonical the site emits by searching one function name.

Validation: curl -s <url> | grep -c 'rel="canonical"' returns exactly 1. Two is as broken as zero.

Step 4: Emit the header only for non-HTML

// middleware.js
import { canonicalUrl } from '@/lib/canonical-url';

export function middleware(request) {
  const res = NextResponse.next();
  const ct = res.headers.get('content-type') ?? '';
  if (ct.includes('text/html')) return res;    // the tag owns HTML

  const { pathname } = new URL(request.url);
  if (pathname.startsWith('/files/') || pathname.endsWith('.xml')) {
    res.headers.set('Link', `<${canonicalUrl(pathname)}>; rel="canonical"`);
  }
  return res;
}

SEO impact: Indexable PDFs and feeds get a canonical they could not otherwise carry, without the header ever touching a page.

Validation: curl -sI https://example.com/files/spec.pdf | grep -i '^link:' returns the canonical; the same command on an HTML route returns nothing.

Step 5: Detect conflicts in continuous integration

#!/usr/bin/env bash
# ci/canonical-conflict-check.sh
set -euo pipefail
fail=0
while read -r url; do
  hdr=$(curl -sI "$url" | grep -io 'link:.*rel="canonical"' || true)
  tag=$(curl -s "$url" | grep -oP '(?<=rel="canonical" href=")[^"]+' || true)
  tags=$(curl -s "$url" | grep -c 'rel="canonical"' || true)

  [ -n "$hdr" ] && { echo "HEADER on HTML: $url"; fail=1; }
  [ "$tags" -gt 1 ] && { echo "DUPLICATE TAG: $url ($tags)"; fail=1; }
  [ -z "$tag" ] && { echo "MISSING CANONICAL: $url"; fail=1; }
done < indexable-urls.txt
exit $fail

Validation: Add a stray header on a branch and confirm the job fails. This check catches the conflict class that is invisible when reading either layer alone.

Step 6: Compare declared against selected

node inspect-batch.js sample.txt \
  | jq -r '.[] | select(.userCanonical != .googleCanonical)
           | [.url, .userCanonical, .googleCanonical] | @tsv'

Validation: Every row is a case where Google disagreed with you. A handful is normal; a pattern by template means the declaration is wrong or the pages are too similar to distinguish.

Where Conflicts Come From

Almost every real conflict has one of four origins, and knowing them shortens the diagnosis considerably.

Four common origins of a canonical conflict Four conflict sources — a CDN page rule, a legacy layout tag, a CMS-supplied field, and an analytics injection — each labelled with the layer that introduces it. Where a second canonical comes from CDN page rule adds a Link header legacy layout hard-coded tag CMS field editor override client script injects after load two declarations, no canonical Only the CI fetch sees all four at once — no single codebase does

SEO Impact Summary

The two mechanisms also differ in how much of the response has to be read before the declaration is available, which matters for large pages and truncated fetches.

How early each declaration becomes available A response drawn as headers followed by a body, showing the Link header readable before any HTML is parsed and the canonical tag readable once the head element has been parsed. response headers head element body Link header readable before any parsing canonical tag readable after head is parsed neither mechanism reaches here Earlier availability is why non-HTML responses can only use the header For an HTML page the difference is immaterial — which is why the tag is the right default
Signal What improves What breaks if misconfigured
Canonical selection One unambiguous declaration per URL, so Google consolidates as intended Conflicting declarations leave Google to pick its own target
Non-HTML indexing PDFs and feeds consolidate onto their HTML equivalents Without a header they compete with the page they duplicate
Duplicate control Trailing-slash and host variants collapse onto one URL Two “correct” canonicals differing by a slash split the signals
Diagnosability A single origin helper makes every canonical greppable Hard-coded origins scattered across layers make conflicts invisible

Measurable signals to watch:

  • Count of canonical declarations per response, asserted at exactly one for HTML.
  • Declared-versus-selected canonical mismatch rate, sampled per template.
  • Presence of a Link canonical on any HTML response, which should always be zero.

Edge Cases and Gotchas

A client-injected canonical is not reliably read Google renders pages, so a canonical inserted during hydration may be seen — but it competes with whatever was in the server HTML, and the timing is not guaranteed. Declare canonicals server-side, exactly as with JSON-LD at the render layer.

An editor-supplied canonical field needs validation If the CMS lets an editor set a canonical, validate that the target resolves to a 200 on your own origin. A canonical pointing at a 404 is worse than none, because it removes the page without promoting anything.

Feeds legitimately have their own canonical An RSS feed’s canonical should point at the feed URL, not at the HTML index it summarises. The two are different resources; consolidating them loses the feed.

Canonicals do not survive a redirect chain If the canonical target itself redirects, the signal is diluted and may be dropped. Point canonicals at the final URL — the auditing method is in auditing redirect hops after a headless migration.

Frequently Asked Questions

Which wins if the tag and the header disagree? Treat the behaviour as undefined. Google sees two conflicting declarations and may pick either, or discard both and choose its own from content and link signals. In practice a conflict means you have no canonical, which is why detecting it matters more than knowing any precedence rule.

Do I need a Link header if I already have the tag? No. It gains nothing and creates a second place the value can drift. Use the header only for responses with no HTML head, and let pages carry the tag alone.

Can a canonical point to a different domain? Yes — it is the standard mechanism for syndicated content and multi-tenant storefronts. Cross-domain canonicals are weaker hints, so the pages need to be genuinely near-identical and the target consistently reachable. The multi-tenant case is covered in cross-domain canonicals for multi-tenant headless.

Should a paginated page carry a canonical to page one? No. Each paginated page should self-canonicalise, because its content genuinely differs. Canonicalising to page one removes the deeper pages and their crawl paths, which is a common and expensive mistake on listing templates.


Part of: Canonical URL Enforcement

Related