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.
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.
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.
| 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
Linkcanonical 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
- Fixing Canonical Mismatches in Next.js App Router — the framework-specific causes of a wrong declared value
- Cross-Domain Canonicals for Multi-Tenant Headless — when the target is deliberately on another host
- Serving X-Robots-Tag From Edge Middleware — the same layer-ownership discipline applied to robots directives