How to check canonical tag

Save the page and its headers in one request with curl -s -D head.txt -o page.html <url>, then search both files for a canonical. The HTML tag can be split across lines, so flatten newlines before you grep. A page may also send a Link: header with rel="canonical", and the two values can disagree.

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

Why check this

Run this on staging sign-off and after any template change that touches the document head. A canonical names which URL is the real one, so a wrong value hands the ranking of a live page to a URL that does not exist. The failure it prevents is concrete: a release ships a relative canonical written for production, staging serves the same template, and every staging page now declares a canonical on the staging hostname.

Prerequisites

Steps

  1. Step 1.

    Fetch the page once and keep the headers and the body.

    curl -s -D mdn.head -o mdn.html -w 'code=%{http_code} bytes=%{size_download}\n' https://developer.mozilla.org/en-US/docs/Web/HTTP
    
    code=200 bytes=221672

    Every later step reads these two files, so the target is fetched once.

  2. Step 2.

    Grep the saved HTML the obvious way.

    grep -o -i '<link[^>]*rel="canonical"[^>]*>' mdn.html; echo "exit=$?"
    
    exit=1

    Nothing matched, and the page does have a canonical. Read step 3 before you report the tag as missing.

  3. Step 3.

    Flatten newlines, then grep the same pattern.

    tr '\n' ' ' < mdn.html | grep -o -i '<link[^>]*rel="canonical"[^>]*>' | tr -s ' '
    
    <link rel="canonical" href="https://developer.mozilla.org/en-US/docs/Web/HTTP" />

    The tag was there the whole time. This template prints the element across four lines, and grep works one line at a time, so the single-line pattern could never match it. The second tr squeezes the spaces that flattening produced.

  4. Step 4.

    Check the response headers for the other delivery method.

    grep -i '^link:' mdn.head; echo "exit=$?"
    
    exit=1

    No Link: header here, so the element is the only source on this page. A PDF can only use the header, having no head section to put an element in.

  5. Step 5.

    Start a target that sends both, and read each source on its own. Save this as canonical-target.js and run node canonical-target.js.

    const http = require('node:http');
    const html = (canon) =>
      `<!doctype html><html><head>\n  <link\n    rel="canonical"\n    href="${canon}"\n  />\n  <title>Canonical target</title>\n</head><body>page</body></html>`;
    http
      .createServer((req, res) => {
        const h = { 'content-type': 'text/html; charset=utf-8' };
        if (req.url === '/both') {
          h.link = '<http://127.0.0.1:8932/from-header>; rel="canonical"';
          res.writeHead(200, h);
          res.end(html('/from-tag'));
        } else if (req.url === '/relative') {
          res.writeHead(200, h);
          res.end(html('/docs/page'));
        } else {
          res.writeHead(200, h);
          res.end(html('http://127.0.0.1:8932/'));
        }
      })
      .listen(8932, () => console.log('canonical target on port 8932'));
    

    Then read the header source and the HTML source separately.

    curl -sI http://127.0.0.1:8932/both | grep -i -E '^HTTP|^link'; curl -s http://127.0.0.1:8932/both | tr '\n' ' ' | grep -o -i '<link[^>]*rel="canonical"[^>]*>' | tr -s ' '
    
    HTTP/1.1 200 OK
    link: <http://127.0.0.1:8932/from-header>; rel="canonical"
    <link rel="canonical" href="/from-tag" />

    Two canonicals, two different URLs, one response. A header-only check sees the first line and passes. An HTML-only check sees the third and passes. Only a check that reads both finds the defect.

  6. Step 6.

    Resolve both sources against the request URL and compare them. Save this as canonical.mjs.

    const res = await fetch(process.argv[2], { redirect: 'follow' });
    const body = await res.text();
    const head = res.headers.get('link');
    const hm = head && /<([^>]+)>\s*;[^,]*rel="?canonical"?/i.exec(head);
    const tm = /<link\b[^>]*rel=["']?canonical["']?[^>]*>/i.exec(body);
    const th = tm && /href=["']([^"']+)["']/i.exec(tm[0]);
    const abs = (v) => (v ? new URL(v, res.url).href : null);
    console.log('requested  ', process.argv[2]);
    console.log('after hops ', res.url);
    console.log('header raw ', hm ? hm[1] : '(none)');
    console.log('header abs ', abs(hm && hm[1]));
    console.log('tag raw    ', th ? th[1] : '(none)');
    console.log('tag abs    ', abs(th && th[1]));
    const a = abs(hm && hm[1]), b = abs(th && th[1]);
    console.log('verdict    ', a && b ? (a === b ? 'agree' : 'CONFLICT') : a || b ? 'single source' : 'NO CANONICAL');
    
    node canonical.mjs http://127.0.0.1:8932/both
    
    requested   http://127.0.0.1:8932/both
    after hops  http://127.0.0.1:8932/both
    header raw  http://127.0.0.1:8932/from-header
    header abs  http://127.0.0.1:8932/from-header
    tag raw     /from-tag
    tag abs     http://127.0.0.1:8932/from-tag
    verdict     CONFLICT

    It resolves each value with new URL(value, requestUrl), the algorithm a browser uses, so the comparison is between absolute URLs, not attribute strings.

  7. Step 7.

    Ask the same relative canonical from two hostnames.

    for h in 127.0.0.1 localhost; do node canonical.mjs "http://$h:8932/relative" | grep -E 'requested|tag raw|tag abs'; done
    
    requested   http://127.0.0.1:8932/relative
    tag raw     /docs/page
    tag abs     http://127.0.0.1:8932/docs/page
    requested   http://localhost:8932/relative
    tag raw     /docs/page
    tag abs     http://localhost:8932/docs/page

    One byte-identical tag, two different canonical URLs. /docs/page resolves against the document that carries it, so the host that served the page decides the answer, not the template. Nobody reading the attribute in isolation can see this. Stop the server afterwards: netstat -ano | grep 8932 names the PID to stop.

  8. Step 8.

    Confirm the resolution in a browser. Open the page, then paste this in the DevTools Console.

    (() => { const l = document.querySelector('link[rel=canonical]');
      return { location: location.href, attribute: l.getAttribute('href'), resolved: l.href }; })()
    
    {"location":"http://localhost:8932/relative","attribute":"/docs/page","resolved":"http://localhost:8932/docs/page"}

    getAttribute('href') returns the raw string, the href property returns the resolved URL. The Elements panel shows you the first one. Compare against the second.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | One element, absolute URL, equal to the request URL | Self-referencing canonical | Nothing. Confirm it with How to check a self-referencing canonical. | | A single-line grep finds nothing | Usually a tag printed across several lines | Flatten newlines as in step 3 before concluding the tag is absent. | | link: header and HTML element with different URLs | Two conflicting declarations | Remove one. Google's own guidance is to pick one method, because using both is error prone. | | A relative href such as /docs/page | Resolves against whatever host served the page | Replace with an absolute URL. Staging otherwise declares staging. | | No canonical anywhere | The crawler picks a URL for you | Add a self-referencing one unless the page is a deliberate duplicate. | | Canonical points at a URL that redirects | The declaration and the routing disagree | Follow it with How to check redirect chain and point the canonical at the final URL. |

Common mistakes

Sign: grep reports no canonical on a page that clearly has one in view-source.Cause: The element is pretty-printed across several lines, as on the MDN page in step 2. grep matches within a single line, so the pattern never sees the whole tag. Flatten the body with tr '\n' ' ' first, or parse the HTML instead of matching it.
Sign: A header-only audit and an HTML-only audit both pass, and the page still has a canonical defect.Cause: A canonical can arrive as a Link response header or as an element in the head, and one response can carry both with different values. Step 5 shows exactly that. No specification and no Google documentation defines which one wins, so a disagreement is not a tie to be predicted, it is a defect to be removed.
Sign: The canonical looks correct in the Elements panel and the wrong URL is indexed.Cause: The Elements panel shows the attribute string. A relative value resolves against the document base, so the same template produces a production canonical on production and a staging canonical on staging. Read the href property, not the attribute, as in step 8.
Sign: A PDF or an image has no canonical and nobody can add one.Cause: Those responses have no head section. The Link header is the only method available for them, which is why an audit that parses HTML only reports every non-HTML asset as uncovered.

What to check next

FAQ

What is a canonical URL?

The URL you declare as the real address of a page when several addresses serve the same content. Search engines use it to consolidate signals onto one address instead of splitting them across duplicates. It is a declaration, not an instruction.

How do I check a canonical URL in Chrome?

Open DevTools, Console tab, and read document.querySelector('link[rel=canonical]').href. The href property returns the resolved absolute URL. The Elements panel shows the raw attribute instead, which hides the resolution shown in step 7.

How do I find a canonical issue?

Compare three values: the URL you requested, the canonical after resolution, and the canonical in the response headers. Any disagreement is the issue. Steps 5 and 6 produce all three.

Should the canonical be absolute or relative?

Absolute. Relative values are supported, and Google's documentation recommends against them because a test host that gets crawled then declares itself. Step 7 shows one identical tag producing two different canonical URLs.

Can a page have a canonical in both the header and the HTML?

Yes, and step 5 builds one. Google documents both methods and recommends choosing a single one, because using both at once is error prone. Treat a disagreement as a failing test rather than guessing a winner.

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.

basic7 minpublished updated Maks Verny