afaqrashid.com  ·  portfolio · 2026 ed.

Next.js Canonical URL: 4 Traps I Tested and the Fix

To give every page in a Next.js App Router site its own canonical URL, set metadataBase and a canonical of "./" in the root layout:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL("https://example.com"),
  alternates: { canonical: "./" },
};

I tested this on Next.js 16.4.4. Each page got a canonical pointing to itself, including a dynamic route, and query strings were dropped:

/                       https://example.com
/blog                   https://example.com/blog
/blog/post              https://example.com/blog/post
/p/hello?utm_source=x   https://example.com/p/hello

A page can still set its own canonical when it needs a different one. Below are the four traps I hit while testing, and one of them doesn't show up in Search Console.

1. A fixed canonical in the layout points every page at the homepage

This is the easy mistake. The homepage needs a canonical, so it goes in the root layout:

// app/layout.tsx
alternates: { canonical: "/" },

Every page that doesn't set its own canonical inherits it. My /blog page came out with this:

<link rel="canonical" href="https://example.com"/>

That tells Google /blog is a copy of your homepage. Google treats a canonical as a strong signal, not an order, but you're asking it to drop the page. Use "./" in the layout instead, or set the homepage's canonical in app/page.tsx.

2. A page's alternates removes the layout's hreflang

Metadata merges shallowly, and alternates is one object. My layout set a canonical and a German alternate. A page that set only its own canonical lost the hreflang link:

/blog        canonical + hreflang="de"   (inherited from the layout)
/blog/post   canonical only              (page set its own alternates)

If you use languages, repeat them in every page that sets alternates. Pulling them into a shared constant keeps that from drifting.

3. Without metadataBase the canonical is relative

With metadataBase removed, my build passed and the page shipped this:

<link rel="canonical" href="/blog/post"/>

Google's advice is to "use absolute paths rather than relative paths" for canonicals. Set metadataBase to your production domain in the root layout.

4. On dynamic pages Googlebot finds the canonical in the body

This one took me longest. When a page uses generateMetadata and renders on request, Next.js streams the metadata. It sends the page first and adds the tags at the end of the body. I fetched a dynamic page as different user agents and checked where the canonical was:

Googlebot smartphone        <body>
Google-InspectionTool       <head>
Bingbot                     <head>

In Chrome the tag stayed in the body after JavaScript ran, too. Next.js decides who gets blocking metadata in the head from a list of user agents. In 16.4.4 that list includes Google-InspectionTool, which Search Console's URL Inspection uses, and Bingbot, but not Googlebot.

Next.js says it has verified that Googlebot reads streamed metadata, because Googlebot runs JavaScript. Google's canonical page says the link "is only accepted if it appears in the <head> section". I can't see inside Google, and the URL Inspection live test won't show you the difference, because it gets the head version.

Where the canonical matters, I'd rather not rely on it. You can add Googlebot to htmlLimitedBots, but a custom value replaces the default list. When I set it to just /Googlebot/, Bingbot started getting the tags in the body. This keeps the 16.4.4 defaults and adds Googlebot:

// next.config.ts
const config: NextConfig = {
  htmlLimitedBots:
    /Googlebot|[\w-]+-Google|Google-[\w-]+|Chrome-Lighthouse|Slurp|DuckDuckBot|baiduspider|yandex|sogou|bitlybot|tumblr|vkShare|quora link preview|redditbot|ia_archiver|Bingbot|BingPreview|applebot|facebookexternalhit|facebookcatalog|Twitterbot|LinkedInBot|Slackbot|Discordbot|WhatsApp|SkypeUriPreview|Yeti|googleweblight/i,
};

With that config, Googlebot and Bingbot got the canonical in the head and normal visitors still got the streamed page. The default list can change between versions; yours is in node_modules/next/dist/shared/lib/router/utils/html-bots.js.

Pages that are prerendered at build time, or use generateStaticParams, don't stream their metadata, so this doesn't apply to them.

Check your canonical the way Googlebot gets it

curl -s https://example.com/blog/post \
  -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
  | grep -oE '</head>|<link rel="canonical"[^>]*>'

If the canonical prints before </head>, it's in the head. If it prints after, it's in the body. The metadata checker below also flags a canonical that's missing, relative, on localhost, or in the body.

Related

Sources