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.
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:
Common issues to look for:
- Canonical pointing to a different domain (usually
metadataBasepointing to a staging URL) - Canonical missing entirely from dynamic pages (forgot to include
alternates.canonicalingenerateMetadata) - 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: