SEO for Next.js

Next.js Canonical URLs: The metadata.alternates Pattern

Canonical URLs in App Router go through metadata.alternates.canonical. Here's the pattern that handles edge cases.

Published May 24, 20268 min readBy RankCrab Team

A canonical tag tells Google which URL is the authoritative version of a page. Without it, Google makes its own determination — and it sometimes gets it wrong. URL variations that create duplicate content problems: HTTP vs HTTPS, www vs non-www, trailing slash vs no trailing slash, query string parameters from analytics or session tracking, paginated pages.

In the App Router, canonical URLs go through metadata.alternates.canonical. Here's the production pattern.

The Basic Pattern

Set the canonical URL in your metadata export or generateMetadata function via alternates.canonical:

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

export const metadata: Metadata = {
  alternates: {
    canonical: '/blog',
  },
}

This renders:

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

The relative URL is resolved against metadataBase — which is why setting metadataBase in your root layout is a prerequisite. Without it, relative canonicals resolve to http://localhost:3000/blog.

Dynamic Pages

For dynamic routes, generate the canonical in generateMetadata using the route params:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params

  return {
    alternates: {
      canonical: `/blog/${slug}`,
    },
  }
}

This is the minimal case. In practice you'll also be setting title, description, and openGraph in the same function — canonical is just one field in the return object.

The Trailing Slash Decision

Trailing slashes create duplicate content if your server serves both /blog and /blog/. Pick one and make it consistent. In Next.js, the two relevant config options are:

// next.config.js
module.exports = {
  trailingSlash: false,  // default: Next.js redirects /blog/ → /blog
  // or
  trailingSlash: true,   // Next.js redirects /blog → /blog/
}

With trailingSlash: false (the default), always write canonicals without trailing slashes:

alternates: {
  canonical: '/blog/my-post',  // correct
  // not '/blog/my-post/'
}

With trailingSlash: true, always include trailing slashes:

alternates: {
  canonical: '/blog/my-post/',
}

Pick one. Inconsistency between your canonical tags and your actual URL structure is the easiest way to generate duplicate content signals.

Query Parameters

Canonical tags are the right tool for handling query-parameterized URLs that serve the same content. A common case: pagination, filtering, or search parameters that don't change the core content.

For a paginated listing page where ?page=1 and the base URL are identical:

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

type Props = {
  searchParams: Promise<{ page?: string }>
}

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  const { page } = await searchParams
  const pageNum = parseInt(page || '1', 10)

  // All paginated pages point to the first page as canonical
  // (only do this if the content genuinely represents the same topic)
  return {
    alternates: {
      canonical: '/blog',
    },
    // Optionally, noindex paginated pages past the first
    robots: pageNum > 1 ? { index: false } : { index: true },
  }
}

hreflang via metadata.alternates.languages

If your site serves multiple languages or regional variants, metadata.alternates.languages handles the hreflang attributes:

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params

  return {
    alternates: {
      canonical: `/blog/${slug}`,
      languages: {
        'en-US': `/en-US/blog/${slug}`,
        'es-ES': `/es-ES/blog/${slug}`,
        'fr-FR': `/fr-FR/blog/${slug}`,
      },
    },
  }
}

This renders:

<link rel="canonical" href="https://yourdomain.com/blog/my-post" />
<link rel="alternate" hreflang="en-US" href="https://yourdomain.com/en-US/blog/my-post" />
<link rel="alternate" hreflang="es-ES" href="https://yourdomain.com/es-ES/blog/my-post" />
<link rel="alternate" hreflang="fr-FR" href="https://yourdomain.com/fr-FR/blog/my-post" />

For multi-locale Next.js apps using the [locale] route segment:

// app/[locale]/blog/[slug]/page.tsx
import type { Metadata } from 'next'

const locales = ['en-US', 'es-ES', 'fr-FR'] as const

type Props = {
  params: Promise<{ locale: string; slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { locale, slug } = await params

  const languages = Object.fromEntries(
    locales.map((l) => [l, `/${l}/blog/${slug}`])
  )

  return {
    alternates: {
      canonical: `/${locale}/blog/${slug}`,
      languages,
    },
  }
}

Self-Referencing Canonicals

Every page should have a canonical tag that points to itself. This is true even for pages with no obvious duplicate risk — it's a defensive practice. Set a self-referencing canonical by default in your root layout, and let page-level generateMetadata override it:

// app/layout.tsx — sets a fallback canonical for any page that doesn't override it
export const metadata: Metadata = {
  metadataBase: new URL('https://yourdomain.com'),
  // Note: don't set alternates.canonical here
  // Next.js doesn't support a "current URL" placeholder in the root layout
  // Pages that need canonicals should set them explicitly
}

The important thing: every high-value page — blog posts, product pages, landing pages — should explicitly set alternates.canonical in its generateMetadata. Don't rely on Google inferring the canonical.

Verifying Canonical Tags

Check that your canonical tags resolve to the right URLs across your site with the RankCrab canonical URL checker:

Free tool
Canonical URL checker
Build a canonical tag the right way — and check the one you have.
Try it

Common issues to look for:

  • Canonical pointing to a different domain (usually metadataBase pointing to a staging URL)
  • Canonical missing entirely from dynamic pages (forgot to include alternates.canonical in generateMetadata)
  • Canonical and actual URL disagree on trailing slash
  • Paginated pages canonicalizing to themselves instead of the first page

For a full audit of duplicate content signals, canonical chain issues, and hreflang mismatches across your site, the 80-check audit covers all of these.

This article is part of the SEO for Next.js cluster. Related reading:

Ship optimized content this afternoon.

7-day trial. $29 when it converts. Cancel from the dashboard the second it stops earning its keep.