Repair Technical SEO

a canonical tag pointing at staging, a redirect or nothing

Search Console says Alternate page with proper canonical tag for pages you very much want indexed, or your live pages are nominating a staging host nobody can reach. Canonical tags are almost always generated from a single base URL, so when that base is wrong the whole site is wrong at once — and the page looks completely normal in a browser, because a canonical is invisible.

No API key needed Python and Node.js Rewrites your HTML
The short answer

Check three things on every page: that the canonical exists, that there is exactly one of them, and that it points at a URL on the live origin that returns 200.

Two canonicals on a page is the worst case — Google ignores all of them, so the tag you added does nothing at all. That usually means a layout and a page template are both emitting one.

The problem in plain words

The failure is invisible from the browser and uniform across the site, which is a bad combination. Nothing looks broken, every page is affected equally, and the only symptom is a Search Console category most people read as informational.

The usual causes are environment configuration: a build-time base URL that was not set in CI, a .env that defaults to localhost, or a staging host that was copied to production. A close second is a path variable being concatenated onto a base that already contains it, producing a canonical with the path twice.

Why it happens

A canonical is a hint, not a directive. Google may pick a different URL if your signals conflict, so a wrong canonical does not always produce an obvious error — sometimes it just quietly loses you the page you wanted ranked.

Multiple canonicals cancel. If a page has more than one rel="canonical", Google ignores all of them and works it out from other signals. Adding a second one to be safe is strictly worse than having one.

A canonical to a redirect or a 404 is a contradiction. You are nominating a URL that the server says is not the right one, or does not exist. Chains behave the same way: point at the final destination.

Origin and path get conflated in configuration. One variable holds the scheme and host; another holds the path prefix. Combining them in the wrong order, or using the one that already includes the path, produces a doubled path — and it is consistent across every page, which makes it look deliberate.

How to fix it

Audit the built HTML, not the templates

Templates look right. The build is where the base URL is substituted, so the audit has to run against the output or the live site. This is the same reason a source review keeps missing it.

Count the canonicals per page

Zero and two are both failures, and two is worse than zero. The script reports the count first because it changes what the other checks mean.

Resolve each canonical target

It must be absolute, on your live origin, and return 200 without redirecting. A relative canonical is legal but resolves against the page's own URL, which produces surprises on paginated or parameterised URLs.

Repair the origin, in place

Because the base URL is the usual culprit, the fix is a mechanical origin swap across the built files. The script does that with --apply, replacing only the scheme-and-host portion — never the path, which is how a greedy rewrite turns a URL into a bare origin.

Fix the build variable so it does not come back

The rewrite fixes this deploy. Set the base URL in the build environment, or the next build reproduces it exactly.

How to check it worked

Ask the live page what it claims:

curl -s https://example.com/some-page/ | grep -c 'rel="canonical"'   # must be 1
curl -s https://example.com/some-page/ | grep -o 'rel="canonical" href="[^"]*"'
curl -sIL "$(curl -s https://example.com/some-page/ | sed -n 's/.*rel="canonical" href="\([^"]*\)".*/\1/p')" | head -1

Then use URL Inspection in Search Console, which reports the canonical you declared and the canonical Google chose as two separate lines. When they differ, that gap is the finding.

The full code

The script reads local built HTML files or live URLs, counts canonicals, resolves each target, and classifies the result. With --apply and --live-origin it rewrites the origin portion of every canonical in place. The origin swap is deliberately narrow — it matches scheme and host only, because a greedy rewrite is how you turn a good URL into a bare origin.

canonical_audit.py
"""Audit and repair rel=canonical tags in built HTML.

Canonicals are generated from one base URL, so when the base is wrong every page is
wrong at once -- and the page looks entirely normal in a browser, because a canonical
is invisible.
"""
import argparse
import logging
import re
import sys
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("canonical_audit")

CANON = re.compile(r'<link[^>]+rel=["\']canonical["\'][^>]*>', re.I)
HREF = re.compile(r'href=["\']([^"\']+)["\']', re.I)


def classify(canonicals, page_url, live_origin):
    """Pure decision function over the canonical tags found on one page.

    canonicals: list of href strings, in document order.
    Returns a list of problems; empty means the page is fine.
    """
    problems = []
    if not canonicals:
        return ["no canonical tag"]
    if len(canonicals) > 1:
        # Worse than none: Google ignores all of them when there is more than one.
        problems.append(f"{len(canonicals)} canonical tags -- Google ignores all of them")
    href = canonicals[0]
    if not href.startswith(("http://", "https://")):
        problems.append(f"relative canonical {href!r} -- resolves against the page URL, "
                        "which surprises on parameterised URLs")
        return problems
    parts = urlsplit(href)
    origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
    if live_origin and origin != live_origin.rstrip("/"):
        problems.append(f"origin {origin} is not the live origin {live_origin}")
    if parts.scheme == "http":
        problems.append("canonical uses http; it should match the served scheme")
    # A path repeated back to back is the classic base-plus-prefix concatenation bug.
    segs = [s for s in parts.path.split("/") if s]
    for i in range(len(segs) - 1):
        if segs[i] and segs[i] == segs[i + 1]:
            problems.append(f"path segment {segs[i]!r} appears twice -- a base URL and a "
                            "path prefix were probably concatenated")
            break
    if page_url and href.rstrip("/") != page_url.rstrip("/") and not problems:
        problems.append(f"canonical {href} differs from the page URL {page_url}; "
                        "correct for a duplicate, wrong for a page you want indexed")
    return problems


def swap_origin(html, old_origin, new_origin):
    """Replace only the scheme-and-host portion of canonical hrefs.

    Narrow on purpose. A greedy origin rewrite is how a good URL becomes a bare
    origin and a sitemap reference becomes nonsense.
    """
    def fix(m):
        return m.group(0).replace(old_origin.rstrip("/"), new_origin.rstrip("/"))
    return CANON.sub(fix, html)


def hrefs_in(html):
    return [HREF.search(tag).group(1) for tag in CANON.findall(html) if HREF.search(tag)]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dir", required=True, help="directory of built HTML")
    ap.add_argument("--live-origin", help="https://example.com")
    ap.add_argument("--from-origin", help="origin to replace when using --apply")
    ap.add_argument("--apply", action="store_true")
    args = ap.parse_args()

    files = sorted(Path(args.dir).rglob("*.html"))
    log.info("%d file(s)", len(files))
    bad = 0
    for f in files:
        html = f.read_text(encoding="utf-8", errors="replace")
        found = hrefs_in(html)
        problems = classify(found, None, args.live_origin)
        if problems:
            bad += 1
            log.warning("%s -- %s", f, "; ".join(problems))
        if args.apply and args.from_origin and args.live_origin:
            fixed = swap_origin(html, args.from_origin, args.live_origin)
            if fixed != html:
                f.write_text(fixed, encoding="utf-8")
                log.info("rewrote canonical origin in %s", f)
    if not args.apply and args.from_origin:
        log.info("WOULD rewrite origins -- pass --apply")
    log.info("%d file(s) with problems", bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
canonical-audit.mjs
/**
 * Audit and repair rel=canonical tags in built HTML.
 *
 * Canonicals are generated from one base URL, so when the base is wrong every page
 * is wrong at once -- and looks entirely normal in a browser.
 */
import { readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';

const CANON = /<link[^>]+rel=["']canonical["'][^>]*>/gi;
const HREF = /href=["']([^"']+)["']/i;

/**
 * Pure decision function over the canonical tags found on one page.
 * Returns a list of problems; empty means fine.
 */
export function classify(canonicals, pageUrl, liveOrigin) {
  const problems = [];
  if (!canonicals.length) return ['no canonical tag'];
  if (canonicals.length > 1) {
    // Worse than none: Google ignores all of them when there is more than one.
    problems.push(`${canonicals.length} canonical tags -- Google ignores all of them`);
  }
  const href = canonicals[0];
  if (!/^https?:\/\//.test(href)) {
    problems.push(`relative canonical "${href}" -- resolves against the page URL, `
      + 'which surprises on parameterised URLs');
    return problems;
  }
  const u = new URL(href);
  const origin = `${u.protocol}//${u.host}`;
  if (liveOrigin && origin !== liveOrigin.replace(/\/$/, '')) {
    problems.push(`origin ${origin} is not the live origin ${liveOrigin}`);
  }
  if (u.protocol === 'http:') problems.push('canonical uses http; it should match the served scheme');
  const segs = u.pathname.split('/').filter(Boolean);
  for (let i = 0; i < segs.length - 1; i += 1) {
    if (segs[i] && segs[i] === segs[i + 1]) {
      problems.push(`path segment "${segs[i]}" appears twice -- a base URL and a path `
        + 'prefix were probably concatenated');
      break;
    }
  }
  if (pageUrl && href.replace(/\/$/, '') !== pageUrl.replace(/\/$/, '') && !problems.length) {
    problems.push(`canonical ${href} differs from the page URL ${pageUrl}; `
      + 'correct for a duplicate, wrong for a page you want indexed');
  }
  return problems;
}

/**
 * Replace only the scheme-and-host portion of canonical hrefs. Narrow on purpose:
 * a greedy origin rewrite is how a good URL becomes a bare origin.
 */
export function swapOrigin(html, oldOrigin, newOrigin) {
  return html.replace(CANON, (tag) =>
    tag.replaceAll(oldOrigin.replace(/\/$/, ''), newOrigin.replace(/\/$/, '')));
}

export const hrefsIn = (html) =>
  (html.match(CANON) ?? []).map((t) => t.match(HREF)?.[1]).filter(Boolean);

const walk = (dir) => readdirSync(dir).flatMap((n) => {
  const p = join(dir, n);
  return statSync(p).isDirectory() ? walk(p) : (p.endsWith('.html') ? [p] : []);
});

async function main() {
  const arg = (n) => process.argv[process.argv.indexOf(n) + 1];
  const dir = arg('--dir');
  const liveOrigin = process.argv.includes('--live-origin') ? arg('--live-origin') : null;
  const fromOrigin = process.argv.includes('--from-origin') ? arg('--from-origin') : null;
  const apply = process.argv.includes('--apply');

  const files = walk(dir).sort();
  console.log(`${files.length} file(s)`);
  let bad = 0;
  for (const f of files) {
    const html = readFileSync(f, 'utf8');
    const problems = classify(hrefsIn(html), null, liveOrigin);
    if (problems.length) { bad += 1; console.warn(`${f} -- ${problems.join('; ')}`); }
    if (apply && fromOrigin && liveOrigin) {
      const fixed = swapOrigin(html, fromOrigin, liveOrigin);
      if (fixed !== html) { writeFileSync(f, fixed); console.log(`rewrote canonical origin in ${f}`); }
    }
  }
  if (!apply && fromOrigin) console.log('WOULD rewrite origins -- pass --apply');
  console.log(`${bad} file(s) with problems`);
  process.exit(bad ? 1 : 0);
}

if (import.meta.url === `file://${process.argv[1]}`) main();

Add a test

The origin swap is the test that matters. It must change the host and leave the path alone — the failure mode is a greedy replace that eats the path and leaves every page canonicalising to the homepage, which is far worse than the bug it was fixing.

test_canonical_audit.py
from canonical_audit import classify, hrefs_in, swap_origin

LIVE = "https://example.com"
TAG = '<link rel="canonical" href="{}">'


def test_a_single_correct_canonical_is_clean():
    assert classify(["https://example.com/a/"], None, LIVE) == []


def test_no_canonical_is_reported():
    assert classify([], None, LIVE) == ["no canonical tag"]


def test_two_canonicals_are_worse_than_none():
    """Google ignores all of them when there is more than one."""
    p = classify(["https://example.com/a/", "https://example.com/b/"], None, LIVE)
    assert any("ignores all of them" in x for x in p)


def test_a_staging_origin_is_reported():
    p = classify(["https://staging.example.net/a/"], None, LIVE)
    assert any("is not the live origin" in x for x in p)


def test_a_doubled_path_segment_is_reported():
    p = classify(["https://example.com/blog/blog/post/"], None, LIVE)
    assert any("appears twice" in x for x in p)


def test_a_relative_canonical_is_reported():
    p = classify(["/a/"], None, LIVE)
    assert any("relative canonical" in x for x in p)


def test_swap_origin_changes_the_host_and_keeps_the_path():
    """The failure mode is a greedy replace that eats the path."""
    html = TAG.format("http://localhost:4321/blog/post/")
    out = swap_origin(html, "http://localhost:4321", LIVE)
    assert out == TAG.format("https://example.com/blog/post/")


def test_swap_origin_leaves_other_links_alone():
    html = ('<a href="http://localhost:4321/x">x</a>'
            + TAG.format("http://localhost:4321/y"))
    out = swap_origin(html, "http://localhost:4321", LIVE)
    assert 'href="http://localhost:4321/x"' in out
    assert 'href="https://example.com/y"' in out


def test_hrefs_in_finds_the_tag():
    assert hrefs_in(TAG.format("https://example.com/a/")) == ["https://example.com/a/"]
canonical-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, hrefsIn, swapOrigin } from './canonical-audit.mjs';

const LIVE = 'https://example.com';
const tag = (h) => `<link rel="canonical" href="${h}">`;

test('a single correct canonical is clean', () => {
  assert.deepEqual(classify(['https://example.com/a/'], null, LIVE), []);
});

test('no canonical is reported', () => {
  assert.deepEqual(classify([], null, LIVE), ['no canonical tag']);
});

test('two canonicals are worse than none', () => {
  const p = classify(['https://example.com/a/', 'https://example.com/b/'], null, LIVE);
  assert.ok(p.some((x) => x.includes('ignores all of them')));
});

test('a doubled path segment is reported', () => {
  const p = classify(['https://example.com/blog/blog/post/'], null, LIVE);
  assert.ok(p.some((x) => x.includes('appears twice')));
});

test('swapOrigin changes the host and keeps the path', () => {
  const out = swapOrigin(tag('http://localhost:4321/blog/post/'), 'http://localhost:4321', LIVE);
  assert.equal(out, tag('https://example.com/blog/post/'));
});

test('swapOrigin leaves other links alone', () => {
  const html = `<a href="http://localhost:4321/x">x</a>${tag('http://localhost:4321/y')}`;
  const out = swapOrigin(html, 'http://localhost:4321', LIVE);
  assert.ok(out.includes('href="http://localhost:4321/x"'));
  assert.ok(out.includes('href="https://example.com/y"'));
});

test('hrefsIn finds the tag', () => {
  assert.deepEqual(hrefsIn(tag('https://example.com/a/')), ['https://example.com/a/']);
});

FAQ

What does 'Alternate page with proper canonical tag' mean?

Google found the page, read its canonical, and is indexing the nominated URL instead. That is correct for a genuine duplicate and a problem when the page is one you wanted indexed on its own.

Can a page have two canonical tags?

It can, and it should not. When Google finds more than one rel=canonical it ignores all of them and decides from other signals. Adding a second one to be safe is strictly worse than having one.

Is a canonical a directive?

No, it is a hint. Google can pick a different URL when your signals conflict, which is why a wrong canonical does not always surface as an obvious error — sometimes it just quietly loses you the page.

Why do all my canonicals point at localhost or staging?

The base URL was not set in the build environment, so the default was used. Every page is affected identically, which makes it look deliberate. Fix the build variable, not just this deploy's output.

Should a canonical point at a redirecting URL?

No. You would be nominating a URL the server says is not the right one. Point at the final destination, and treat a canonical to a 404 as the same class of contradiction.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.