DEV Community

Posting Dude
Posting Dude

Posted on

A Canonical in Your Next.js Root Layout Lands on Every Page. 6 Ways a Canonical Quietly Cancels Itself

Last week on three of our launch sites (EarlyHunt, MakerHunt and SideHunt) every article under /posts had a canonical tag pointing at the homepage. 14 articles, all quietly telling Google "I'm just a copy of the homepage". Nobody typed that on purpose. The canonical was set once in the Next.js root layout, and every route that didn't override it just inherited it. The pages looked fine, nothing errored, nothing in the logs. We fixed it on Oct 21 and each article now points at itself.

That one got me curious about how many other ways a canonical can quietly cancel itself. So this morning I set up a tiny Next.js 16.4.3 test app, broke it on purpose a few ways, and wrote a small checker. Below is what I found, with the real output.

Quick refresher first. Google's doc on specifying a canonical URL lists three ways to state your preference, in order of strength: redirects (strong), rel="canonical" (strong), sitemap inclusion (weak). The rel="canonical" can go in two places:

  • a <link rel="canonical"> element in the HTML <head>
  • a Link: <url>; rel="canonical" HTTP response header

The header is the only option for non-HTML files like PDFs. Google says pick one of the two, because using both "is more error prone" (you end up with one URL in the header and another in the HTML). And it's a hint, not a command. Google can still pick its own canonical.

Here are the six ways I'd check, roughly in the order I'd expect to hit them.

1. A canonical in a shared layout gets inherited by every child page

This is the one that got us. In the App Router, metadata from app/layout.js down to the page.js is merged. The Next.js metadata docs say segments are "shallowly merged" and "duplicate keys are replaced based on their ordering". So if a child page never sets alternates, it keeps the root's.

My test root layout:

// app/layout.js
export const metadata = {
  metadataBase: new URL('https://acme.test'),
  title: 'Acme',
  alternates: { canonical: '/' },
}
Enter fullscreen mode Exit fullscreen mode

And a blog post page that only sets a title:

// app/posts/[slug]/page.js
export async function generateMetadata({ params }) {
  const { slug } = await params
  return { title: `Post ${slug}` }   // no alternates
}
Enter fullscreen mode Exit fullscreen mode

After next build && next start:

== GET /
<link rel="canonical" href="https://acme.test"/>
== GET /posts/hello-world
<link rel="canonical" href="https://acme.test"/>
Enter fullscreen mode Exit fullscreen mode

Same canonical on the post as on the homepage. That's our bug, reproduced in two files.

Fix: keep metadataBase in the root layout (that part is fine and the docs recommend it there), but set the canonical per page:

// app/posts/[slug]/page.js
export async function generateMetadata({ params }) {
  const { slug } = await params
  return {
    title: `Post ${slug}`,
    alternates: { canonical: `/posts/${slug}` },
  }
}
Enter fullscreen mode Exit fullscreen mode
== GET /fixed/hello-world
<link rel="canonical" href="https://acme.test/fixed/hello-world"/>
Enter fullscreen mode Exit fullscreen mode

And put the homepage's canonical: '/' in app/page.js, not the layout. This isn't only a Next.js thing btw. Any framework where a shared layout or theme writes the <head> can do it. Google's old 2013 post on rel=canonical mistakes describes people copying a page template "without thinking to change the target of the rel=canonical".

2. Adding hreflang (or anything under alternates) deletes the inherited canonical

This one surprised me more. Because the merge is shallow, alternates is one key. If a page sets alternates.languages and nothing else, the root's alternates.canonical is gone, not merged.

// app/lang/page.js
export const metadata = {
  title: 'Lang',
  alternates: { languages: { 'de-DE': '/de/lang' } },
}
Enter fullscreen mode Exit fullscreen mode
== GET /lang
<link rel="alternate" hrefLang="de-DE" href="https://acme.test/de/lang"/>
canonical count: 0
Enter fullscreen mode Exit fullscreen mode

Zero canonicals. Here it's kind of a lucky accident, since the inherited one was wrong anyway. But flip it around: if your layout had a correct per-route canonical helper and someone adds hreflang to one page, that page silently loses it. Same goes for openGraph, the docs show a child openGraph replacing the whole parent object. If you set alternates anywhere below the root, always set canonical in the same object.

3. Relative paths, and the ./ thing

Google recommends absolute URLs. Relative ones are supported but "can cause problems in the long run (for example, if you unintentionally allow your testing site to be crawled)". The 2013 post has a nice example: writing example.com/cupcake.html without https:// makes it a relative path, so the canonical becomes https://example.com/example.com/cupcake.html.

In Next.js you write relative paths and metadataBase turns them absolute. Per the docs, a relative URL field without metadataBase is a build error, which is a good guardrail. One gotcha: the docs' URL composition table says ./ resolves to the bare metadataBase (https://acme.com). In my test on 16.4.3, canonical: './' on /dotslash resolved to the page's own path:

== GET /dotslash
<link rel="canonical" href="https://acme.test/dotslash"/>
Enter fullscreen mode Exit fullscreen mode

So either the table is about something else or the behaviour changed, I didn't dig further. Either way I wouldn't rely on ./. Write the full path.

4. Two canonical tags

Google's 2013 post is blunt here: "Specify no more than one rel=canonical for a page. When more than one is specified, all rel=canonical links will be ignored." It mentions SEO plugins adding a default one without the site owner knowing.

In a Next.js app, the easy way to get two is having alternates.canonical in metadata and also a hand-written <link rel="canonical"> somewhere in a layout from before you moved to the Metadata API. Or one in HTML and a different one in the Link header from your CDN or host config. Count them, don't just grab the first one. A lot of quick checks do $('link[rel=canonical]').attr('href'), which only reads the first match, so two tags pass.

5. A canonical that points at a 404, a redirect, or a noindex page

The tag can be perfect and the target can still be broken. Google's 2013 checklist: "Double-check that your rel=canonical target exists (it's not an error or soft 404)" and "Verify the rel=canonical target doesn't contain a noindex robots meta tag". A canonical to a URL that redirects is also a mixed signal. You're saying "the real one is over there" and over there says "no, it's somewhere else".

Common ways it happens on small sites: canonical still points at the staging domain, you renamed a route and redirected it but the canonical helper builds the old path, or http vs https / www vs non-www mismatch between the canonical and the URL you actually serve.

You can check this with curl (curl -s -o /dev/null -w "%{http_code}" <canonical-url>, no -L), or if you'd rather paste a URL, a free canonical tag checker I built on Aura++ requests the canonical target and flags anything that isn't a 200. It also reads the Link header and compares against og:url. No signup.

6. The canonical ends up in <body>

This is the newest one and I'm honestly not sure how much it matters, so here's what I can show.

Since Next.js 15.2, generateMetadata can stream. From the docs: when it resolves, "the resulting metadata tags are appended to the <body> tag". Next.js says they "have verified that metadata is interpreted correctly by bots that execute JavaScript and inspect the full DOM (e.g. Googlebot)". For "HTML-limited bots" (it detects these by user agent, e.g. facebookexternalhit) metadata stays in the <head>.

Google's canonical doc, on the other hand, says the element "is only accepted if it appears in the <head> section of the HTML".

I tested a dynamic route whose generateMetadata awaits searchParams, with three user agents:

/stream UA=curl/8: canonical byte 5160, </head> byte 590 -> IN BODY
/stream UA=Mozilla/5.0 (compatible; Googlebot/2.1; ...): IN BODY
/stream UA=facebookexternalhit/1.1: canonical byte 544, </head> byte 666 -> IN HEAD
Enter fullscreen mode Exit fullscreen mode

I also loaded it in headless Chrome and checked the rendered DOM. The canonical was still inside <body> after JavaScript ran. Static and SSG pages (like /posts/hello-world above) had it in the head.

I can't see inside Googlebot, so I won't tell you this breaks anything. Next.js says it's fine, Google's doc wording says head. If you don't want to bet on it, the docs give htmlLimitedBots in next.config.js. I set it to /Googlebot/ and re-ran:

htmlLimitedBots=/Googlebot/ UA=Mozilla/5.0 (compatible; Googlebot/2.1; ...) -> IN HEAD
Enter fullscreen mode Exit fullscreen mode

The docs warn this "could lead to longer response times", so it's a trade off. Another option is making the metadata not depend on request data so the route can prerender.

Bonus: og:url that disagrees

og:url isn't one of Google's canonical signals. But if your canonical and og:url disagree, it usually means two pieces of code build "the URL of this page" separately, and one of them is wrong. That's a good place to look for bugs #1 and #3.

PDFs: the Link header

For a PDF there's no <head>, so the header is the only way. In Next.js you can add it in next.config.js:

module.exports = {
  async headers() {
    return [{
      source: '/whitepaper.pdf',
      headers: [{ key: 'Link', value: '<https://acme.test/whitepaper>; rel="canonical"' }],
    }]
  },
}
Enter fullscreen mode Exit fullscreen mode
$ curl -sI localhost:3125/whitepaper.pdf
HTTP/1.1 200 OK
Link: <https://acme.test/whitepaper>; rel="canonical"
Content-Type: application/pdf
Enter fullscreen mode Exit fullscreen mode

Just don't also send a header on HTML pages that already have a tag, that's back to #4.

The quick check

Fastest version is two curls. Run them on a deep page, not the homepage, because the homepage is where a wrong inherited canonical looks right. Here on github.com just to show the output:

curl -s https://github.com/ | grep -io '<link[^>]*rel="canonical"[^>]*>'
curl -sI https://github.com/ | grep -i '^link:'
Enter fullscreen mode Exit fullscreen mode
<link rel="canonical" href="https://github.com" data-turbo-transient>
# (second command printed nothing, so no Link header)
Enter fullscreen mode Exit fullscreen mode

For more than eyeballing, here's the small Node script I used (Node 18+, no dependencies). It reads server HTML only, it doesn't run JavaScript, which is also how I wanted it, so it tells you where the tag is before any script moves things around.

// check-canonical.mjs  usage: node check-canonical.mjs <url> [url...]
// Node 18+, no dependencies. Reads server HTML only (no JavaScript run).
const attr = (tag, name) =>
  (tag.match(new RegExp(`${name}\\s*=\\s*["']([^"']*)["']`, 'i')) || [])[1];

async function check(url) {
  const res = await fetch(url, { redirect: 'follow', headers: { 'user-agent': 'canonical-check/1.0' } });
  const html = await res.text();
  const finalUrl = res.url;
  const headEnd = html.search(/<\/head>/i);
  const problems = [];

  // 1. canonical in the HTML (anywhere, so we can tell head vs body)
  const tags = [...html.matchAll(/<link\b[^>]*>/gi)]
    .filter(m => /rel\s*=\s*["']?canonical["']?/i.test(m[0]))
    .map(m => ({ href: attr(m[0], 'href'), inHead: headEnd === -1 || m.index < headEnd }));

  // 2. canonical in the HTTP Link header
  const linkHdr = res.headers.get('link') || '';
  const hdr = (linkHdr.match(/<([^>]+)>\s*;[^,]*rel="?canonical"?/i) || [])[1];

  // 3. og:url
  const og = [...html.matchAll(/<meta\b[^>]*>/gi)].map(m => m[0])
    .find(t => /property\s*=\s*["']og:url["']/i.test(t));
  const ogUrl = og && attr(og, 'content');

  console.log(`\n${url}`);
  console.log(`  status ${res.status}${finalUrl !== url ? ` (ended at ${finalUrl})` : ''}`);
  console.log(`  html canonical: ${tags.length ? tags.map(t => t.href + (t.inHead ? '' : ' [IN BODY]')).join(' | ') : 'none'}`);
  console.log(`  header canonical: ${hdr || 'none'}`);
  console.log(`  og:url: ${ogUrl || 'none'}`);

  if (!tags.length && !hdr) problems.push('no canonical declared');
  if (tags.length > 1) problems.push(`${tags.length} canonical tags (Google may ignore all of them)`);
  if (tags.some(t => !t.inHead)) problems.push('canonical outside <head>');
  if (hdr && tags.length && new URL(hdr, finalUrl).href !== new URL(tags[0].href, finalUrl).href)
    problems.push('header and html canonical disagree');

  const raw = tags[0]?.href ?? hdr;
  if (raw) {
    if (!/^https?:\/\//i.test(raw)) problems.push(`relative canonical "${raw}"`);
    const target = new URL(raw, finalUrl).href;
    const norm = u => u.replace(/\/$/, '');
    if (norm(target) !== norm(finalUrl)) problems.push(`points elsewhere: ${target}`);
    const t = await fetch(target, { redirect: 'manual', headers: { 'user-agent': 'canonical-check/1.0' } });
    console.log(`  canonical target status: ${t.status}${t.headers.get('location') ? ' -> ' + t.headers.get('location') : ''}`);
    if (t.status !== 200) problems.push(`canonical target returns ${t.status}`);
    if (ogUrl && norm(new URL(ogUrl, finalUrl).href) !== norm(target)) problems.push(`og:url differs: ${ogUrl}`);
  }
  console.log(problems.length ? problems.map(p => '  ! ' + p).join('\n') : '  ok, no findings');
}

for (const u of process.argv.slice(2)) {
  try { await check(u); } catch (e) { console.log(`\n${u}\n  fetch failed: ${e.message}`); }
}
Enter fullscreen mode Exit fullscreen mode

Run on three URLs this morning:

node check-canonical.mjs https://example.com/ https://github.com/ https://www.github.com/
Enter fullscreen mode Exit fullscreen mode
https://example.com/
  status 200
  html canonical: none
  header canonical: none
  og:url: none
  ! no canonical declared

https://github.com/
  status 200
  html canonical: https://github.com
  header canonical: none
  og:url: https://github.com/
  canonical target status: 200
  ok, no findings

https://www.github.com/
  status 200 (ended at https://github.com/)
  html canonical: https://github.com
  header canonical: none
  og:url: https://github.com/
  canonical target status: 200
  ok, no findings
Enter fullscreen mode Exit fullscreen mode

example.com doesn't declare a canonical at all. That's not wrong, Google says you'll "likely do just fine" without one and it'll pick the best version itself. github.com is the textbook version: one absolute self canonical, og:url matches (the trailing slash difference is normalised in the script), target returns 200. And www.github.com redirects to github.com, so the redirect and the canonical agree, which is what you want.

Things the script doesn't do: it won't run JavaScript, it only follows the first Link header canonical, and the og:url and "points elsewhere" checks treat a trailing slash as the same URL. Good enough to catch the six cases above on a deep page.

After you fix it, check what Google actually picked

The tag is your vote, not the result. In Search Console's URL Inspection tool, look at "Google-selected canonical" for one of the affected pages. Google's troubleshooting doc also says re-evaluation takes time, even after a fix it "might hold pages in a duplicate cluster for up to two weeks". So don't panic if it doesn't flip the next day. There's a longer walkthrough on Aura++ about reading URL Inspection when your own name doesn't show up on Google, and a leftover canonical is one of the first things it checks.

For our 14 posts, it's too early to say if the fix changed anything in search, and I'm not going to guess.

The rule I'm keeping

Any time I add or change a layout, template or SEO helper that touches metadata, I open one deep page (a blog post, not the homepage) and check the canonical before calling it done. The homepage is exactly where a wrong inherited canonical looks correct.

Have you ever checked what canonical your blog posts actually point to? And if you're on Next.js 15.2+ with dynamic metadata, did you notice yours landing in <body>, and did you change anything because of it?

Top comments (0)