Canonical URL в Next.js 15: без дублей в индексе — KEL IT
Сайты 8 мин чтения

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-wwwexample.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 сольёт страницы и выберет одну языковую версию для всех регионов.

Проверка и типичные ошибки перед релизом

Перед запуском рекламной кампании прогоните чеклист:

  1. View Source на production — один <link rel="canonical"> на страницу, абсолютный href, без query.
  2. Редиректыcurl -I https://www.example.com/pricing/ должен вернуть цепочку 301 к https://example.com/pricing.
  3. Search Console → URL Inspection — «Выбранный пользователем canonical» совпадает с вашим.
  4. Screaming Frog / Sitebulb — отчёт «Canonicalised» без неожиданных пар.
  5. Vercel preview — убедитесь, что NEXT_PUBLIC_SITE_URL не подставляет vercel.app в canonical на production.
ОшибкаСимптомИсправление
Нет metadataBasecanonical на *.vercel.appEnv-переменная + 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.

KEL IT

Нужна разработка под ключ?

Я занимаюсь такими проектами профессионально. Telegram-боты, Mini Apps, сайты, мобильные и десктопные приложения. Расскажите о задаче — разберём и предложим решение.