Canonical bugs are the quiet kind. The page looks fine, it ranks for a while, and then a theme change or a new plugin points rel="canonical" at the wrong URL. Google follows the tag, consolidates the wrong version, and your traffic slides. No error, no 404, nothing in the logs.
If you build or maintain sites, you can catch this in code. Here is how to read a page's canonical URL in Node, classify what it means, check many pages at once, and even test IP canonicalization.
Where canonical URLs actually live
Most people check the HTML. There are really two places a canonical can be declared, and a good check reads both:
- The HTML
<link rel="canonical" href="...">in the<head>. - The HTTP
Linkresponse header:Link: <https://example.com/>; rel="canonical".
That header version is easy to forget. It is common on PDFs and non-HTML resources, and some CDNs and frameworks set it. If the header and the HTML disagree, search engines can get conflicting signals, so you want to catch that too.
Reading the canonical in Node
Node 18+ ships a global fetch, so no dependencies are needed for a basic check:
async function getCanonical(url) {
const res = await fetch(url, {
redirect: "follow",
headers: { "User-Agent": "canonical-check/1.0" },
});
let canonical = null;
let source = null;
// 1) HTTP Link header
const linkHeader = res.headers.get("link");
if (linkHeader) {
const m = linkHeader.match(/<([^>]+)>\s*;\s*rel=["']?canonical["']?/i);
if (m) {
canonical = m[1];
source = "http-header";
}
}
// 2) HTML <link rel="canonical">
if (!canonical) {
const html = await res.text();
const tag = html.match(/<link[^>]+rel=["']canonical["'][^>]*>/i);
if (tag) {
const href = tag[0].match(/href=["']([^"']+)["']/i);
if (href) {
canonical = href[1];
source = "html";
}
}
}
return { requested: res.url, canonical, source };
}
For production I would reach for cheerio to parse the HTML instead of a regex, since regex on HTML breaks on edge cases. But for a quick audit script, the pattern above is enough.
Classifying what the canonical means
Finding the tag is half the job. The useful part is what it tells you:
function classify(requested, canonical) {
if (!canonical) return "missing";
const norm = (u) => u.replace(/\/+$/, "").toLowerCase();
return norm(canonical) === norm(requested)
? "self-referencing" // the healthy default
: "points-elsewhere"; // intentional for duplicates, a bug otherwise
}
- self-referencing: the page points to itself. This is what you want on a normal, unique page.
- points-elsewhere: fine for a duplicate or a paginated page consolidating to page one, a problem when it is accidental.
- missing: search engines pick their own preferred version, which is a coin flip you do not want to leave to chance.
Put together:
const { requested, canonical, source } = await getCanonical("https://example.com/");
console.log(classify(requested, canonical), canonical, `(${source ?? "none"})`);
Bulk-checking a whole site
One page is never the real job. After a migration you want to scan a list:
const urls = [
"https://example.com/",
"https://example.com/blog/",
"https://example.com/pricing/",
];
for (const url of urls) {
try {
const { requested, canonical } = await getCanonical(url);
console.log(`${classify(requested, canonical).padEnd(16)} ${url} -> ${canonical ?? "MISSING"}`);
} catch (e) {
console.log(`error ${url} -> ${e.message}`);
}
}
Feed it your sitemap URLs and you have a canonical audit in a few seconds. Add a small concurrency limit if the list is long so you do not hammer the server.
The one people miss: IP canonicalization
Here is a subtle one. If your server answers on its raw IP address as well as your domain, search engines can index both http://203.0.113.10/ and https://example.com/ as separate sites. That splits link equity for content that is literally identical.
The test: resolve the domain to its IP, request the IP directly, and check whether it redirects back to the domain.
import dns from "node:dns/promises";
const host = "example.com";
const { address: ip } = await dns.lookup(host);
const res = await fetch(`http://${ip}/`, { redirect: "manual" });
const location = res.headers.get("location") || "";
if ([301, 302, 307, 308].includes(res.status) && location.includes(host)) {
console.log("PASS: the IP redirects to your domain");
} else if (res.status >= 200 && res.status < 300) {
console.log("FAIL: the IP serves content directly (duplicate content risk)");
} else {
console.log("Blocked or no response, which usually means no IP duplicate content");
}
Note that on shared hosting the IP often belongs to many sites, so this test is most meaningful on a dedicated IP or behind your own reverse proxy.
When you just need an answer, not a script
For a one-off check, or to hand to someone who does not write Node, a browser tool is faster. I built a free one that does all of the above: Pixellize Canonical URL Checker. It reads both the HTML tag and the HTTP Link header, flags self-referencing vs points-elsewhere vs missing, has a bulk mode for up to 20 URLs with CSV export, and an IP canonicalization test. It runs in the browser, nothing is uploaded.
If you want the non-code background on canonicals, these two guides go deeper: how to find the canonical URL of any website and how to change a canonical URL.
Takeaways
- Canonicals live in two places: the HTML
<link>and the HTTPLinkheader. Check both, and flag conflicts. - In Node,
fetchplus a small parser is enough to read and classify canonicals. - Bulk-scan your sitemap after every migration or theme change, since that is when canonicals silently break.
- Do not forget IP canonicalization if you run your own server or reverse proxy.
How do you audit canonicals in your stack? Let me know in the comments.
Top comments (0)