How to check a self-referencing canonical

Fetch the URL, resolve its canonical against the request URL, then compare the two after normalisation: node selfcanon.mjs <url> prints self-ref yes or names the difference. A canonical that differs only by a trailing slash, path case, a query string or the host prefix is not self-referencing.

Checker offline. Follow the manual steps below, they give the same answer.

Why check this

Run this in regression on every URL shape a template can produce, not on one sample page. A self-referencing canonical is how a page claims itself. When it points one character away, the page hands its own signals to an address that may never be crawled, and Search Console reports the live URL as "Alternate page with proper canonical tag", which reads like a pass.

A canonical that points at a different page is visible to anyone. One that points at the same page under a slightly different URL is not, because a browser renders both identically and the bodies are byte for byte the same.

Prerequisites

Steps

  1. Step 1.

    Start a target that serves one page under several URL shapes. Save it as self-canonical-target.js, run node self-canonical-target.js, then confirm two of those shapes are indistinguishable.

    const http = require('node:http');
    const map = { '/guide': '/guide/', '/Docs/': '/docs/' };
    http
      .createServer((req, res) => {
        const path = req.url.split('?')[0];
        let c = map[path] || path;
        if (path === '/host') c = 'http://127.0.0.1:8933/host';
        if (path === '/ok') c = `http://${req.headers.host}${req.url}`;
        const abs = c.startsWith('http') ? c : `http://localhost:8933${c}`;
        res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
        res.end(
          `<!doctype html><html><head><link rel="canonical" href="${abs}">` +
            `<title>Self canonical</title></head><body>one body for every URL</body></html>`
        );
      })
      .listen(8933, () => console.log('self-canonical target on port 8933'));
    
    for u in http://localhost:8933/guide http://localhost:8933/guide/; do curl -s -o body.html -w 'code=%{http_code} url=%{url_effective} ' "$u"; md5sum body.html | cut -c1-32; done
    
    code=200 url=http://localhost:8933/guide 16b196a5053485c81634a91c28ac478c
    code=200 url=http://localhost:8933/guide/ 16b196a5053485c81634a91c28ac478c

    Two URLs, no redirect, the same status and the same checksum. Nothing in the response tells you which one is meant to be canonical.

  2. Step 2.

    Save the comparison script as selfcanon.mjs, then run it on the URL that looks correct in a browser.

    const res = await fetch(process.argv[2], { redirect: 'follow' });
    const body = await res.text();
    const tag = /<link\b[^>]*rel=["']?canonical["']?[^>]*>/i.exec(body);
    const href = tag && /href=["']([^"']+)["']/i.exec(tag[0])[1];
    if (!href) { console.log('no canonical on', res.url); process.exit(1); }
    const strip = (u) => { const n = new URL(u); n.hash = ''; return n; };
    const a = strip(res.url), b = strip(new URL(href, res.url).href);
    const d = [];
    if (a.protocol !== b.protocol) d.push(`scheme ${a.protocol} vs ${b.protocol}`);
    if (a.host !== b.host) d.push(`host ${a.host} vs ${b.host}`);
    if (a.pathname !== b.pathname)
      d.push(a.pathname.replace(/\/$/, '') === b.pathname.replace(/\/$/, '') ? 'trailing slash'
        : a.pathname.toLowerCase() === b.pathname.toLowerCase() ? 'path case' : 'path');
    if (a.search !== b.search) d.push(`query "${a.search}" vs "${b.search}"`);
    console.log('request   ', a.href);
    console.log('canonical ', b.href);
    console.log('self-ref  ', d.length ? 'NO: differs by ' + d.join('; ') : 'yes');
    

    It compares the URL after redirects, res.url, against the canonical resolved against it. Both sides pass through the URL parser, so scheme case, host case and a default port never appear as a difference.

    node selfcanon.mjs http://localhost:8933/guide
    
    request    http://localhost:8933/guide
    canonical  http://localhost:8933/guide/
    self-ref   NO: differs by trailing slash

    One character. The canonical names an address the visitor did not request, and both URLs answer 200, so no redirect corrects the mismatch.

  3. Step 3.

    Run the other three shapes that fail the same way.

    for u in http://localhost:8933/Docs/ 'http://localhost:8933/article?utm_source=newsletter' http://localhost:8933/host; do node selfcanon.mjs "$u" | grep -E '^request|^self-ref'; done
    
    request    http://localhost:8933/Docs/
    self-ref   NO: differs by path case
    request    http://localhost:8933/article?utm_source=newsletter
    self-ref   NO: differs by query "?utm_source=newsletter" vs ""
    request    http://localhost:8933/host
    self-ref   NO: differs by host localhost:8933 vs 127.0.0.1:8933

    Each normalises differently, which is why one rule cannot cover them. Path case is preserved by the parser. A stripped tracking parameter is usually correct. A host difference almost never is.

  4. Step 4.

    See which differences the parser erases and which it keeps. Save as normalise.mjs and run it.

    const pairs = [
      ['HTTPS://EXAMPLE.COM/Docs/', 'https://example.com/Docs/'],
      ['https://example.com:443/a', 'https://example.com/a'],
      ['https://xn--e1afmkfd.example.com/', 'https://пример.example.com/'],
      ['https://example.com/caf%C3%A9', 'https://example.com/café'],
      ['https://example.com/a', 'https://example.com/a/'],
      ['https://example.com/?', 'https://example.com/'],
      ['https://example.com/a%7Eb', 'https://example.com/a~b'],
      ['https://example.com/?b=2&a=1', 'https://example.com/?a=1&b=2'],
    ];
    for (const [x, y] of pairs) {
      const a = new URL(x).href, b = new URL(y).href;
      console.log((a === b ? 'same ' : 'DIFF ') + a.padEnd(33) + ' ' + b);
    }
    
    same https://example.com/Docs/         https://example.com/Docs/
    same https://example.com/a             https://example.com/a
    same https://xn--e1afmkfd.example.com/ https://xn--e1afmkfd.example.com/
    same https://example.com/caf%C3%A9     https://example.com/caf%C3%A9
    DIFF https://example.com/a             https://example.com/a/
    DIFF https://example.com/?             https://example.com/
    DIFF https://example.com/a%7Eb         https://example.com/a~b
    DIFF https://example.com/?b=2&a=1      https://example.com/?a=1&b=2

    Scheme case, host case, the default port, Unicode hostnames and non-ASCII path characters all collapse to one form, so a report about any of them is noise. The last four lines are real differences and need a decision. Line 7 is the one to remember: %7E and ~ are the same character, and RFC 3986 section 6.2.2.2 says a normaliser should decode it. The parser in Node and in every browser does not, so a string comparison calls two equivalent URLs different.

  5. Step 5.

    Confirm a passing URL, then check one in the browser.

    node selfcanon.mjs http://localhost:8933/ok
    
    request    http://localhost:8933/ok
    canonical  http://localhost:8933/ok
    self-ref   yes

    For the browser view, open http://localhost:8933/guide and run this in the DevTools Console.

    (() => { const l = document.querySelector('link[rel=canonical]');
      return { location: location.href, resolved: l.href, equal: location.href === l.href }; })()
    
    {"location":"http://localhost:8933/guide","resolved":"http://localhost:8933/guide/","equal":false}

    Stop the server when you are done. Find the listener with netstat -ano | grep 8933 and stop that single PID rather than every Node process on the machine.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | self-ref yes | Request URL and canonical match after normalisation | Nothing. Keep the case in regression for this URL shape. | | differs by trailing slash | Two URLs serve the page and the canonical names the other one | Pick one shape, redirect the other to it, and make the canonical follow the redirect target. | | differs by path case | The path is case sensitive and the canonical uses a different case | Redirect the non-canonical case. Path case is never normalised away. | | differs by query and the canonical has none | Tracking parameters stripped | Correct in most cases. Wrong when the parameter changes the content, such as a page number. | | differs by host | The canonical names a different hostname | Check the host rules with How to check www vs non-www canonical URL. | | differs by scheme | The canonical still says http: | Fix the template. A canonical on http: undoes an HTTPS migration. | | no canonical | Nothing to compare | Add one. See How to check canonical tag. |

Common mistakes

Sign: Search Console reports a live page as 'Alternate page with proper canonical tag' and the team reads it as a pass.Cause: That status means the URL was crawled and dropped in favour of the canonical it declares. When the canonical is meant to be self-referencing, the message is the defect report, not the all clear. Compare the crawled URL with the canonical, as in step 2.
Sign: A visual review of staging finds nothing and the canonical is still wrong.Cause: Both URL shapes answer 200 with an identical body, as step 1 shows by checksum. A browser gives no signal either. Only a string comparison of the request URL against the resolved canonical finds it.
Sign: An audit script reports a canonical mismatch that turns out to be nothing.Cause: It compared raw strings. Scheme case, host case, a default port, an internationalised hostname and a non-ASCII path character all have two spellings for one URL, and step 4 shows the parser collapsing all five. Parse both sides before comparing.
Sign: Two URLs that RFC 3986 calls equivalent compare as different.Cause: A percent-encoded unreserved character such as %7E for a tilde should be decoded during normalisation. Node and browsers leave it encoded, so the comparison in step 4 reports DIFF. Decode unreserved characters yourself before comparing paths.

What to check next

FAQ

Do canonical links require a full domain?

No. A relative value resolves against the document and is allowed. Use an absolute URL anyway: Google's documentation recommends it, and a relative canonical on a crawlable test host points at the test host.

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

Google crawled that URL and indexed the canonical it declares instead. That is correct for a deliberate duplicate. On a page that should be canonical itself, the canonical points elsewhere, the failure this procedure finds.

Does a trailing slash make a canonical non-self-referencing?

Yes. /guide and /guide/ are different URLs and the parser keeps the difference, as step 4 shows. Serve one shape, redirect the other, and make the canonical name the shape that survives the redirect.

Should a canonical keep the query string?

Keep parameters that change the content, such as a page number or a product variant. Drop parameters that do not, such as campaign tags. Step 3 shows the second case, where the canonical strips utm_source and the request URL keeps it.

Is a self-referencing canonical required?

No. Google documents it as recommended rather than required, and a page with no canonical is still indexed. It removes the guesswork when a parameter or a second hostname produces a copy of the URL.

Verified

Verified by Maks Vernycurl 8.21.0node 22.23.2Chrome 152

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate8 minpublished updated Maks Verny