Canonical URL в Next.js 15: как убрать дубли и не потерять позиции
Лендинг готов: LCP в зелёной зоне, JSON-LD на месте, sitemap отправлен в Search Console. Через месяц в отчёте «Покрытие» появляются десятки URL с пометкой «Дублируется: пользователь не указал канонический вариант». Страница доступна по https://example.com, https://www.example.com, https://example.com/ и https://example.com?utm_source=telegram — для Google это четыре разных адреса с одним контентом. Ссылочный вес распыляется, в выдаче может показаться не тот вариант, который вы продвигали.
Canonical URL — явный сигнал поисковику: «индексируй вот этот адрес, остальные — копии». В Next.js 15 App Router каноникал настраивается через metadata.alternates.canonical, но одного тега <link rel="canonical"> недостаточно. Нужны согласованные редиректы, единый metadataBase и единая политика trailing slash — иначе canonical и фактический URL расходятся, и Google игнорирует директиву.
В этой статье — production-подход для SEO-лендинга на Next.js 15: от корневого layout.tsx до middleware на Vercel Edge и динамических страниц услуг.
Почему дубли URL убивают SEO лендинга
Дублирование возникает не только при копипасте контента на разные домены. На современном стеке источники дублей скромнее, но не менее опасны:
| Источник дубля | Пример | Риск |
|---|---|---|
| www / non-www | example.com vs www.example.com | Два независимых origin в индексе |
| Trailing slash | /pricing vs /pricing/ | Два URL с идентичным HTML |
| HTTP → HTTPS | редирект настроен, canonical — нет | Временное дублирование при крауле |
| UTM и query-параметры | ?utm_source=telegram | Параметрические копии без canonical |
| Preview / draft | ?preview=true из headless CMS | Индексация черновика |
| Локали без hreflang | /ru и /en с похожим текстом | Конкуренция URL внутри сайта |
Для коммерческого лендинга последствия конкретны: внешние ссылки с форумов и каталогов могут вести на www., а canonical указывает на bare domain — PageRank не консолидируется. В Search Console вы видите «правильную» страницу как «Страница с переадресацией», а в выдаче — вариант с UTM-параметром, который вы никогда не продвигали.
Google обрабатывает canonical как подсказку, не как приказ. Если сервер отдаёт 200 на обоих вариантах slash и canonical стоит только на одном — краулер может выбрать другой URL. Поэтому canonical, 301-редиректы и внутренняя перелинковка должны указывать на один и тот же финальный адрес.
metadataBase и alternates.canonical в App Router
В Next.js 15 метаданные собираются через export const metadata или generateMetadata. Базовая настройка начинается в корневом 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: '/',
},
// ... title, description, openGraph
};
metadataBase — якорь для всех относительных URL в metadata: Open Graph, canonical, alternates. Без него Next.js сгенерирует canonical относительно deployment URL на Vercel (*.vercel.app), и в production вы получите каноникал на preview-домен — классическая ошибка при первом деплое.
Для внутренних страниц задавайте canonical относительным путём — Next.js склеит его с metadataBase:
// app/pricing/page.tsx
export const metadata: Metadata = {
title: 'Тарифы — разработка лендингов',
alternates: {
canonical: '/pricing',
},
};
Итоговый HTML:
<link rel="canonical" href="https://example.com/pricing" />
Динамические маршруты
На страницах услуг /services/[slug] canonical формируется в generateMetadata:
// app/services/[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 {
title: `Услуга: ${slug}`,
alternates: {
canonical: `/services/${slug}`,
},
};
}
Важно: canonical должен совпадать с финальным URL после редиректов. Если middleware перенаправляет /services/web-dev/ → /services/web-dev, canonical обязан быть без завершающего слэша (или наоборот — но единообразно по всему сайту).
Абсолютный canonical — когда нужен
Относительный путь предпочтителен: при смене домена достаточно обновить NEXT_PUBLIC_SITE_URL. Абсолютный canonical (canonical: 'https://example.com/pricing') оправдан при кросс-доменных сценариях — например, лендинг на landing.example.com, а каноникал на основном example.com/pricing.
Если нужна подобная разработка — напишите в Telegram.
Middleware: www, HTTPS и trailing slash на Edge
Тег canonical без HTTP-редиректов — половина решения. Пользователь и бот по-прежнему получают 200 на «неправильном» URL. На Vercel это закрывается Edge Middleware:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const CANONICAL_HOST = 'example.com'; // без www
export function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
const host = request.headers.get('host') ?? '';
// www → non-www
if (host.startsWith('www.')) {
url.host = CANONICAL_HOST;
return NextResponse.redirect(url, 301);
}
// trailing slash: убираем, кроме корня
if (url.pathname.length > 1 && url.pathname.endsWith('/')) {
url.pathname = url.pathname.slice(0, -1);
return NextResponse.redirect(url, 301);
}
return NextResponse.next();
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|avif)$).*)',
],
};
Политика slash должна совпадать с trailingSlash в next.config.ts. По умолчанию Next.js 15 работает без завершающего слэша. Если вы включили trailingSlash: true, middleware должен добавлять slash, а не убирать — и canonical тоже с slash.
Исключения из редиректов
Не гоняйте через slash-редирект:
/_next/*— статика и chunks;- API routes (
/api/*), если они не должны попадать в индекс; - файлы с расширением — matcher выше их отсекает.
Для preview-режима headless CMS добавьте проверку: если url.searchParams.has('preview'), отдавайте X-Robots-Tag: noindex в middleware, а canonical не указывайте на preview-URL.
Query-параметры, локали и согласованность с sitemap
UTM-метки (utm_source, utm_medium, utm_campaign) не должны создавать отдельные канонические URL. Страница https://example.com/pricing?utm_source=telegram должна иметь:
<link rel="canonical" href="https://example.com/pricing" />
Next.js не добавляет query к canonical автоматически — это правильное поведение. Убедитесь, что внутренние ссылки в JSX ведут на чистые пути без UTM: метки только во внешних кампаниях.
Согласованность с sitemap
В app/sitemap.ts URL должны байт-в-байт совпадать с canonical:
// app/sitemap.ts
import type { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL!;
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: `${baseUrl}/`, lastModified: new Date() },
{ url: `${baseUrl}/pricing`, lastModified: new Date() },
{ url: `${baseUrl}/services/web-dev`, lastModified: new Date() },
];
}
Расхождение sitemap ↔ canonical — частая причина «Обнаружено, не проиндексировано»: Google получает противоречивые сигналы и откладывает краул.
Мультиязычный лендинг
Если лендинг на /ru и /en, canonical уникален для каждой локали:
// app/[locale]/pricing/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
return {
alternates: {
canonical: `/${locale}/pricing`,
languages: {
'ru-RU': '/ru/pricing',
'en-US': '/en/pricing',
'x-default': '/en/pricing',
},
},
};
}
languages генерирует hreflang — canonical при этом остаётся локаль-специфичным. Ошибка: один canonical /pricing для обеих локалей — Google сольёт страницы и выберет одну языковую версию для всех регионов.
Проверка и типичные ошибки перед релизом
Перед запуском рекламной кампании прогоните чеклист:
- View Source на production — один
<link rel="canonical">на страницу, абсолютныйhref, без query. - Редиректы —
curl -I https://www.example.com/pricing/должен вернуть цепочку 301 кhttps://example.com/pricing. - Search Console → URL Inspection — «Выбранный пользователем canonical» совпадает с вашим.
- Screaming Frog / Sitebulb — отчёт «Canonicalised» без неожиданных пар.
- Vercel preview — убедитесь, что
NEXT_PUBLIC_SITE_URLне подставляетvercel.appв canonical на production.
| Ошибка | Симптом | Исправление |
|---|---|---|
Нет metadataBase | canonical на *.vercel.app | Env-переменная + new URL() в layout |
| Canonical с www, редирект на non-www | «Canonical does not match» в GSC | Единый хост в middleware и metadata |
Два canonical в <head> | Валидатор HTML ругается | Один источник: layout или page, не оба |
noindex + canonical | Страница выпадает из индекса | Убрать robots: { index: false } с прод-страниц |
| HTTP в canonical, сайт на HTTPS | Смешанные сигналы | Всегда https:// в metadataBase |
На staging с Basic Auth canonical всё равно должен указывать на production URL — иначе после переноса Google переобучается на новый адрес месяцами.
Нужна помощь? Telegram → или vic.kell@ya.ru
FAQ
Достаточно ли только canonical без 301-редиректов?
Нет. Canonical — подсказка для индексации; 301 — жёсткое правило маршрутизации. Идеальная схема: редирект с дубля на канонический URL плюс canonical на целевой странице. Так вы закрываете и ботов, и пользователей с закладками на старый адрес.
Нужен ли canonical на главной /?
Да. Укажите alternates: { canonical: '/' } в корневом layout. Без этого Google может выбрать /?ref=partner или index.html как основной URL, если такие ссылки существуют во внешнем профиле.
Как canonical влияет на Open Graph?
metadataBase общий для OG и canonical. Если canonical указывает на example.com, а openGraph.url — на www.example.com, превью в Telegram и сниппет в Google расходятся. Держите openGraph.url согласованным с canonical или не задавайте его — Next.js выведет из metadata.
Что делать с пагинацией блога на лендинге?
Для /blog?page=2 canonical обычно указывает на саму страницу пагинации (/blog?page=2), а не на /blog — иначе Google проигнорирует глубокие страницы. Альтернатива: rel="prev" / rel="next" (устаревающая практика) или одна страница с бесконечной прокруткой и pushState — но для SEO-блога отдельные URL надёжнее.
Работает ли canonical между поддоменами?
Да, если указать абсолютный URL. Лендинг на promo.example.com может канонизировать на example.com/offer. Убедитесь, что контент действительно дублируется — иначе Google проигнорирует кросс-доменный canonical и проиндексирует обе версии отдельно.
Заключение
Canonical в Next.js 15 — это не одна строка в metadata, а согласованная система: metadataBase в layout, alternates.canonical на каждой индексируемой странице, 301-редиректы www и trailing slash в Edge Middleware, чистые URL в sitemap и внутренних ссылках. Когда все слои указывают на один адрес, Search Console перестаёт плодить дубли, ссылочный вес консолидируется, и лендинг конкурирует в выдаче одним URL — тем, который вы контролируете.
Настройте canonical до запуска трафика: исправлять дубли после индексации дороже, чем закрыть их в layout.tsx и middleware.ts до первого деплоя на Vercel.