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
A tracking parameter that leaves the content unchanged can canonicalize to the clean URL. A later page in a collection shows different items and usually needs its own canonical. Google’s pagination guidance recommends a unique URL and canonical for each page in a sequence.
This example assumes page values have already been validated against the collection’s available pages:
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 = Number(page ?? '1')
return {
alternates: {
canonical: pageNum > 1 ? `/blog?page=${pageNum}` : '/blog',
},
}
}
Validate integers and out-of-range pages in the route itself; this snippet is only the metadata layer. Expose crawlable anchor links between pages. Do not add noindex to every later page or point all of them to page one simply because the layout or topic repeats.
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
The canonical URL utility normalizes a supplied URL and builds a tag. It does not fetch pages, inspect pasted HTML or verify that the target resolves. Preserve meaningful pagination/filter parameters and verify the actual published output separately:
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
- Distinct paginated pages incorrectly canonicalizing to the first page
When the declared target differs from Google’s selection, use the canonical diagnosis workflow. The checker does not reveal the indexed canonical.
This article is part of the SEO for Next.js cluster. Related reading: