Canonical URLs in Next.js 15: One URL to Rule Your Landing Page
Your landing page passes Core Web Vitals. JSON-LD is live. The sitemap is in Search Console. A month later, Coverage reports fill with “Duplicate: user did not select canonical.” The same page answers at example.com, www.example.com, example.com/, and example.com?utm_source=newsletter — four URLs, one HTML document. Link equity splits. Google may rank a variant you never intended to promote.
A canonical URL tells crawlers which address owns the content. In Next.js 15 App Router you declare it through metadata.alternates.canonical — but a <link rel="canonical"> tag alone won’t save you. Redirects, metadataBase, and a consistent trailing-slash policy must all point to the same final URL, or Google treats your canonical as a weak hint and picks something else.
This guide walks through a production setup: root layout metadata, Edge middleware on Vercel, dynamic service pages, and pre-launch validation.
How Duplicate URLs Erode Landing Page SEO
Duplicates aren’t only cross-domain copy-paste. Modern stacks create subtler clones:
| Source | Example | Risk |
|---|---|---|
| www / non-www | example.com vs www.example.com | Two origins in the index |
| Trailing slash | /pricing vs /pricing/ | Identical HTML, two URLs |
| Residual HTTP | redirect exists, canonical missing | Brief duplicate during crawl |
| UTM parameters | ?utm_source=newsletter | Parameterized clones |
| CMS preview | ?preview=true | Draft pages indexed |
| Locales without hreflang | /ru and /en overlap | Internal URL competition |
For commercial landings the damage is tangible: backlinks land on www. while canonical says bare domain — PageRank doesn’t consolidate. Search Console shows your “correct” URL as “Page with redirect” while a UTM variant appears in results.
Google treats canonical as a hint. If both slash variants return 200 and only one has a canonical tag, the crawler may choose differently. Canonical tags, 301 redirects, and internal links must agree on one destination.
metadataBase and alternates.canonical
Next.js 15 builds metadata from export const metadata or generateMetadata. Start in the root layout:
// app/layout.tsx
import type { Metadata } from 'next';
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com';
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
alternates: {
canonical: '/',
},
};
metadataBase anchors every relative URL in metadata — Open Graph, canonical, alternates. Skip it and Next.js may canonicalize to your *.vercel.app preview host. A classic first-deploy mistake.
Inner pages use relative canonical paths; Next.js joins them with metadataBase:
// app/pricing/page.tsx
export const metadata: Metadata = {
title: 'Pricing — landing page development',
alternates: {
canonical: '/pricing',
},
};
Output:
<link rel="canonical" href="https://example.com/pricing" />
Dynamic routes
For /services/[slug], build canonical inside generateMetadata:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
return {
alternates: {
canonical: `/services/${slug}`,
},
};
}
The canonical must match the post-redirect URL. If middleware strips trailing slashes, canonical paths must do the same everywhere.
Prefer relative canonicals — change NEXT_PUBLIC_SITE_URL when the domain moves. Use absolute canonicals only for cross-domain consolidation (e.g. landing.example.com → example.com/pricing).
If you need help shipping this setup — message on Telegram.
Edge Middleware: www, HTTPS, and Trailing Slash
Canonical tags without HTTP redirects leave duplicate 200 responses. On Vercel, Edge Middleware enforces one URL shape:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const CANONICAL_HOST = 'example.com';
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const host = request.headers.get('host') ?? '';
if (host.startsWith('www.')) {
url.host = CANONICAL_HOST;
return NextResponse.redirect(url, 301);
}
if (url.pathname.length > 1 && url.pathname.endsWith('/')) {
url.pathname = url.pathname.slice(0, -1);
return NextResponse.redirect(url, 301);
}
return NextResponse.next();
}
Align this with trailingSlash in next.config.ts. Default Next.js 15 behavior omits trailing slashes. If you set trailingSlash: true, middleware should add slashes — and canonicals must follow.
Exclude /_next/*, API routes you don’t want indexed, and static files via the matcher. For CMS preview mode, return X-Robots-Tag: noindex when ?preview is present — never canonicalize preview URLs.
Query Strings, Locales, and Sitemap Alignment
UTM parameters must not become canonical URLs. https://example.com/pricing?utm_source=newsletter should still canonicalize to https://example.com/pricing. Next.js correctly omits query strings from generated canonicals; keep internal links clean — UTMs belong in external campaigns only.
Your app/sitemap.ts entries must exactly match canonical URLs:
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: `${baseUrl}/pricing`, lastModified: new Date() },
];
};
Mismatched sitemap ↔ canonical pairs are a common trigger for “Discovered — currently not indexed.”
For /ru and /en landings, each locale gets its own canonical plus languages alternates for hreflang:
alternates: {
canonical: `/${locale}/pricing`,
languages: {
'ru-RU': '/ru/pricing',
'en-US': '/en/pricing',
'x-default': '/en/pricing',
},
},
One shared canonical across locales makes Google collapse language versions into a single result.
Pre-Launch Checklist
Before paid traffic hits:
- View Source — one canonical per page, absolute
href, no query string. curl -I—https://www.example.com/pricing/chains 301 tohttps://example.com/pricing.- Search Console URL Inspection — “User-selected canonical” matches yours.
- Crawl tool — no surprise canonical pairs.
- Production env —
NEXT_PUBLIC_SITE_URLisn’t avercel.apphost.
| Mistake | Symptom | Fix |
|---|---|---|
Missing metadataBase | Canonical on preview domain | Env var in root layout |
| www in canonical, redirect to non-www | GSC mismatch warning | One host everywhere |
| Duplicate canonical tags | HTML validation errors | Single source per page |
noindex + canonical | Page drops from index | Remove robots.index: false on prod |
Need help? Telegram → or vic.kell@ya.ru
FAQ
Is canonical enough without 301 redirects?
No. Canonical guides indexing; 301 enforces routing. Best practice: redirect duplicates to the canonical URL and emit a matching canonical tag on the destination.
Does the homepage need a canonical?
Yes — alternates: { canonical: '/' } in the root layout. Without it, Google may prefer /?ref=partner if external links use that shape.
How does canonical relate to Open Graph?
metadataBase is shared. If canonical says example.com but openGraph.url says www.example.com, social previews and SERP signals diverge. Align them or omit openGraph.url and let Next.js derive it.
What about blog pagination?
/blog?page=2 should typically canonicalize to itself, not to /blog — otherwise deep pages get deprioritized. Separate paginated URLs remain the most reliable approach for indexable blogs.
Cross-subdomain canonicals?
Supported with absolute URLs. promo.example.com can point at example.com/offer — but only when content truly duplicates; otherwise Google indexes both separately.
Conclusion
Canonical URLs in Next.js 15 are a system, not a single metadata field: metadataBase in layout, per-page alternates.canonical, Edge middleware for www and slash policy, and sitemap URLs that mirror canonicals exactly. When every layer agrees, Search Console stops multiplying duplicates, link equity consolidates, and your landing competes with one URL — the one you control.
Configure canonicals before traffic launches. Fixing duplicate indexing after the fact costs more than setting layout.tsx and middleware.ts correctly on the first Vercel deploy.