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
- Node 22. The comparison uses the built-in
URLparser, which applies the same normalisation as a browser. - curl 7.x or later for step 1.
- Google's note that a self-referential canonical is recommended, in consolidating duplicate URLs, and RFC 3986 section 6 on URL equivalence.
Steps
- Step 1.
Start a target that serves one page under several URL shapes. Save it as
self-canonical-target.js, runnode 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; donecode=200 url=http://localhost:8933/guide 16b196a5053485c81634a91c28ac478c code=200 url=http://localhost:8933/guide/ 16b196a5053485c81634a91c28ac478cTwo URLs, no redirect, the same status and the same checksum. Nothing in the response tells you which one is meant to be canonical.
- 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 theURLparser, so scheme case, host case and a default port never appear as a difference.node selfcanon.mjs http://localhost:8933/guiderequest http://localhost:8933/guide canonical http://localhost:8933/guide/ self-ref NO: differs by trailing slashOne character. The canonical names an address the visitor did not request, and both URLs answer 200, so no redirect corrects the mismatch.
- 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'; donerequest 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:8933Each 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.
- Step 4.
See which differences the parser erases and which it keeps. Save as
normalise.mjsand 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=2Scheme 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:
%7Eand~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. - Step 5.
Confirm a passing URL, then check one in the browser.
node selfcanon.mjs http://localhost:8933/okrequest http://localhost:8933/ok canonical http://localhost:8933/ok self-ref yesFor the browser view, open
http://localhost:8933/guideand 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 8933and 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
What to check next
- How to check canonical tag: how to read the value in the first place, from the HTML and from the response header.
- How to check www vs non-www canonical URL: the host difference from step 3, checked through the redirect chain.
- How to check redirect chain: a canonical should name the final URL of a chain, not a hop inside it.
- How to check if a redirect is 301 or 302: the status that makes a trailing slash rule permanent.
- Meta tag checker: reports the resolved canonical of one URL rather than the raw attribute.
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.
Related on this site
- Checker: meta-tags title, description, canonical, robots meta, hreflang, Open Graph, Twitter card, viewport
- All crawlability and indexing checks
intermediate8 minpublished updated Maks Verny