Skip to main content

How to Set a Canonical URL in Next.js (App Router)

7 min readBy SEO Snapshot

The Metadata API, not a hand-written tag

In the App Router you don't hand-write <link rel="canonical"> in the <head>. You describe it through the Metadata API, and Next.js renders the tag for you. That's cleaner, but it hides a trap that has deindexed a lot of pages — including some of ours — because canonicals set in a layout are inherited by every route below it. Get the mechanics right and you'll never think about it again. Get them wrong once in layout.tsx and Google quietly points your whole site at the homepage.

If you're fuzzy on what a canonical actually does and when you need one, read canonical URLs explained first — this post is the Next.js implementation, not the concept.

The basic pattern: alternates.canonical

Every canonical in the App Router lives under alternates in a metadata object:

// app/pricing/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'Pricing',
  alternates: {
    canonical: '/pricing',
  },
};

export default function PricingPage() {
  return <main>{/* ... */}</main>;
}

That renders <link rel="canonical" href="https://example.com/pricing" /> into the head — as long as you've set metadataBase (next section). A relative string like /pricing is the right call here: it's self-referencing, and it survives a domain change without a find-and-replace.

Set metadataBase once in the root layout

Relative URLs need a base to resolve against. Set it a single time in the root layout and every relative canonical, plus your Open Graph url, becomes absolute:

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  title: {
    default: 'Acme',
    template: '%s | Acme',
  },
  // NOTE: no `alternates.canonical` here — see the gotcha below
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

With metadataBase set, canonical: '/pricing' resolves to https://example.com/pricing. Without it, Next.js falls back to http://localhost:3000 in dev and logs a warning, and in production you can end up shipping a canonical that points at the wrong origin. Set it from an env var if you deploy to multiple domains:

metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com'),

You can pass an absolute string per page (canonical: 'https://example.com/pricing') and skip the base, but then OG URLs still need a base and you're maintaining the domain in two mental models. Pick relative + metadataBase and stay consistent.

Dynamic routes: a self-referencing canonical from the slug

Static metadata can't see route params. For a [slug] page, use generateMetadata, which receives params and builds the canonical from the current slug:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getPost } from '@/lib/posts';

export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  const post = await getPost(params.slug);

  return {
    title: post.title,
    description: post.excerpt,
    alternates: {
      canonical: `/blog/${params.slug}`,
    },
  };
}

export default async function PostPage({ params }: { params: { slug: string } }) {
  const post = await getPost(params.slug);
  return <article>{/* ... */}</article>;
}

Each post now self-canonicalizes to its own URL. The key detail: the canonical is derived from params.slug, not hard-coded. If you ever paste canonical: '/blog/some-fixed-slug' in here, every post points at one URL and the rest fall out of the index. Same failure mode as the layout trap, smaller blast radius.

The gotcha that deindexes your whole site

Metadata in the App Router is merged down the tree. A page inherits everything from its parent layouts and overrides only the fields it sets. metadataBase and title.template inheriting is exactly what you want. alternates.canonical inheriting is not.

If you write this in the root layout:

// app/layout.tsx — DON'T DO THIS
export const metadata: Metadata = {
  metadataBase: new URL('https://example.com'),
  alternates: { canonical: 'https://example.com' }, // hard-coded homepage
};

...then every route that doesn't set its own canonical inherits https://example.com. Your pricing page, your blog posts, your docs — all telling Google "the canonical version of me is the homepage." Search Console starts filling up with Duplicate, Google chose different canonical than user and Alternate page with proper canonical tag, and the affected pages drop out of the index. It looks like a content or authority problem. It's one wrong line in a layout.

We hit this exact thing. The pages weren't thin, weren't duplicated, weren't slow — they were all pointing at / because of an inherited canonical, and Google did precisely what we told it to.

The fix is a rule, not a patch:

  • Never hard-code a canonical in the root layout. Set metadataBase there and nothing else canonical-related.
  • Set a self-referencing canonical per route (canonical: '/pricing', canonical: '/blog/${slug}'), or set none and let each page resolve to its own URL.
  • If you inherited this mess, audit it: request each page's HTML and check whether the canonical matches the page's own URL. When several unrelated pages all show the homepage as canonical, an ancestor layout is the source.

Wrongly canonicalizing several URLs at one target is a close cousin of keyword cannibalization — different mechanism, same symptom of pages disappearing from search — and both belong on any technical SEO audit.

Client components can't export metadata

export const metadata and generateMetadata only work in Server Components. Add 'use client' to a file and the metadata export is silently ignored — no error, no canonical. If you need interactivity on a page that also needs a canonical, split it: a Server Component page.tsx owns the metadata and renders a client child.

// app/dashboard/page.tsx  (Server Component — exports metadata)
import type { Metadata } from 'next';
import DashboardClient from './DashboardClient';

export const metadata: Metadata = {
  title: 'Dashboard',
  alternates: { canonical: '/dashboard' },
};

export default function DashboardPage() {
  return <DashboardClient />;
}
// app/dashboard/DashboardClient.tsx  ('use client' — all the interactivity)
'use client';
import { useState } from 'react';

export default function DashboardClient() {
  const [tab, setTab] = useState('overview');
  return <section>{/* interactive UI */}</section>;
}

The page component stays a thin server wrapper; everything stateful lives in the client child.

hreflang is a separate tag — you often need both

rel="canonical" and hreflang solve different problems, and one does not replace the other. Canonical picks the single indexable version of this page. Hreflang tells Google which URL serves which language/region so it shows the right one to the right user. On a multilingual site you usually need both, and the canonical for each language version must point at itself, never at another language. Canonicalizing your /de/ page to /en/ tells Google to drop the German page — the opposite of what you want. The App Router expresses hreflang under alternates.languages; the full setup, including the x-default and the reciprocity rules, is in the hreflang guide.

Verifying it actually rendered

Don't trust the source — check it. View source (not the inspector, which shows post-hydration DOM) and confirm the tag:

curl -sL https://example.com/pricing | grep -i 'rel="canonical"'

You want the canonical to match the page's own URL, use https, and match your OG url exactly — a canonical on https:// while og:url sits on http:// (or with/without a trailing slash mismatch) sends conflicting signals. Run the URL through the analyzer at seosnapshot.dev and it'll surface the canonical, flag a protocol or host mismatch, and check that og:url lines up — the fast way to catch an inherited-canonical bug across a batch of pages before Google does.

FAQ

Relative or absolute canonical? Either renders correctly once metadataBase is set — Next.js makes relative ones absolute for you. Prefer relative (/pricing): it's portable across domains and can't drift from your real origin. Absolute is fine if you're not using metadataBase at all.

Do I need a canonical on every page? You don't strictly have to, but a self-referencing canonical on every indexable page is the safest default. It preempts duplicates from query strings, trailing slashes, and tracking parameters. The one thing to avoid is a single hard-coded canonical shared across many pages.

What if two pages genuinely need the same canonical? That's the correct use of canonical — point the duplicates at the one you want indexed, and make sure that target canonicalizes to itself. If the pages aren't actually duplicates, don't merge them with a canonical; you're just hiding pages that should each rank. Fix the overlap at the content level instead.

Check your site's SEO score for free

Analyze your site