Странствующий техник
Подкинуть работёнку
Назад к статьям
DEV27 июля 2026·9 MIN

Гибридная пагинация: «Показать ещё» + номерные страницы без дублей в SEO

Как совместить UX-паттерн Load More с индексируемыми /page/2 в Next.js: self-referencing canonical, отдельный API-эндпоинт пагинации и без дублей в поиске.

Кнопка «Показать ещё» удобна пользователю и вредна для SEO: контент подгружается через JS, каждая порция живёт на одном и том же URL, а поисковый бот индексирует только первый экран. Номерные страницы /page/2, /page/3 решают индексацию, но в лоб добавленные поверх — плодят дубли контента и размывают релевантность. Ниже — рабочая схема, которая даёт пользователю привычный Load More, а поисковику — чистый список уникальных, канонических страниц без дублей. С готовым API-контрактом и кодом для Next.js App Router.

Обычный путь ведёт к классическим SEO-ошибкам

Обычный сценарий — попросить AI-агента (Cursor, Claude Code, OpenCode, не важно) написать пагинацию, получить рабочий код и перейти дальше. Проблема в том, что "рабочий" и "не создающий дублей в поиске" — разные критерии, и большинство агентов по умолчанию оптимизируют под первый. Спроси любую модель "сделай пагинацию для списка товаров" — с высокой вероятностью получишь либо чистый client-side Load More без единого краулируемого URL, либо номерные страницы с canonical, указывающим на первую страницу (стандартная, но разрушительная рекомендация из SEO-гайдов 2015-2018 годов).

Я пошёл другим путём: вместо генерации кода с нуля отдал Claude Code design-документ пагинации на архитектурный review — до того, как писать код, а не после. Промпт был не "напиши пагинацию", а "проверь, не создаёт ли эта схема дублей контента в поиске". Модель нашла оба слабых места в схеме (canonical не на ту страницу, отсутствие фолбэка для краулеров без JS) на этапе спеки — до того, как эти ошибки попали в код и превратились в переписывание уже задеплоенных роутов. Это тот случай, когда AI полезнее не как автор кода, а как ревьюер архитектуры.

Почему обе крайности — плохой SEO

Чистый Load More (infinite scroll). Все элементы дозагружаются JS-ем на один и тот же URL. У поисковика нет ссылки, которую можно обойти краулером — только onClick-обработчик или IntersectionObserver. Google частично умеет исполнять JS при индексации, но это не гарантия: обход отложен, ресурсоёмок и не срабатывает для большинства других краулеров (Bing, Yandex, соцсети для OpenGraph-превью). Итог — в индекс попадает только первая страница списка, весь «хвост» невидим.

Чистая номерная пагинация без дедупликации. Проблема хуже: /products?page=1, /products?page=2 и так далее — валидные, краулируемые URL, но контент на них почти идентичен (общий layout, повторяющиеся сниппеты карточек, общие мета-теги). Поисковик видит десятки почти одинаковых страниц и либо схлопывает их в один канонический вариант по своему усмотрению (не факт, что в ваш вариант), либо распыляет ссылочный вес между дублями — это классический duplicate content, который проседает в ранжировании всего раздела.

Правильный ответ — не выбор одного из двух, а разделение ответственности: URL с номером страницы существует всегда (для краулера и для шаринга), а Load More — это клиентская надстройка поверх того же URL-пространства, которая подгружает следующую страницу без перезагрузки, но с обновлением URL через history.pushState.

Архитектура: один источник данных, два способа потребления

В основе — простой backend-контракт пагинации, из которого может брать данные и SSR-страница /page/2, и клиентский Load More:

# app/core/pagination.py
from fastapi import Query
from pydantic import BaseModel
from typing import Generic, TypeVar
 
T = TypeVar("T")
 
class PageParams:
    def __init__(self, page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100)):
        self.page = page
        self.page_size = page_size
 
    @property
    def offset(self) -> int:
        return (self.page - 1) * self.page_size
 
class Page(BaseModel, Generic[T]):
    items: list[T]
    total: int
    page: int
    page_size: int

Эндпоинт отдаёт items, total, page, page_size — этого достаточно, чтобы фронтенд посчитал число страниц (Math.ceil(total / page_size)) и решил, рендерить ли кнопку «Показать ещё» или блок номерных ссылок. Никакой особой SEO-логики на бэкенде не нужно — вся хитрость в том, как фронтенд превращает эти данные в URL.

Next.js: страница /page/[n] как источник правды для краулера

Каждая страница пагинации — это отдельный серверный роут с собственным page.tsx, а не query-параметр в клиентском стейте:

Вот финальный вариант кода — он уже прошёл проверку нейросети именно на отсутствие дублей: canonical смотрит сам на себя, а не на первую страницу, и <noscript>-фолбэк добавлен не постфактум, а сразу в первой версии.

app/
└── products/
    ├── page.tsx           # /products → редирект-эквивалент /products/page/1
    └── page/
        └── [n]/
            └── page.tsx   # /products/page/2, /products/page/3, ...
// app/products/page/[n]/page.tsx
import type { Metadata } from 'next'
 
async function getProducts(page: number) {
  const res = await fetch(`https://api.example.com/products?page=${page}&page_size=20`)
  return res.json() as Promise<{ items: Product[]; total: number; page: number; page_size: number }>
}
 
export async function generateMetadata({ params }: { params: { n: string } }): Promise<Metadata> {
  const page = Number(params.n)
  const canonicalUrl = `https://example.com/products/page/${page}`
 
  return {
    title: page === 1 ? 'Все товары' : `Все товары — страница ${page}`,
    alternates: { canonical: canonicalUrl },
  }
}
 
export default async function ProductsPage({ params }: { params: { n: string } }) {
  const page = Number(params.n)
  const data = await getProducts(page)
  const totalPages = Math.ceil(data.total / data.page_size)
 
  return (
    <>
      <ProductGrid items={data.items} />
      <LoadMoreOrPager page={page} totalPages={totalPages} />
    </>
  )
}

Ключевой момент — alternates.canonical указывает на саму себя, а не на первую страницу списка. Это главная ошибка, которую разработчики копируют из старых гайдов: canonical на page=1 со всех остальных страниц заставляет Google считать страницы 2, 3, 4 неканоническими дублями первой — и выкидывать их содержимое из индекса вообще, вместе с товарами, которых на первой странице нет.

Google официально отказался от поддержки rel="next"/rel="prev" ещё в 2019 году — сигнал больше не влияет на ранжирование в Google. Но у self-referencing canonical на каждой странице пагинации такой проблемы нет: он не пытается схлопнуть страницы в одну, а честно говорит «вот уникальный URL, вот его контент».

Bing и Яндекс rel=next/prev частично ещё учитывают, так что тег не будет лишним, но проектировать SEO-стратегию вокруг него в 2026 году — ставка на устаревший сигнал. Self-referencing canonical + уникальный <title> на каждой странице — вот что реально держит индексацию.

Клиентский Load More поверх серверных страниц

Кнопка «Показать ещё» дозагружает следующую страницу через тот же API-эндпоинт, но вместо тихого добавления в DOM без изменения URL — обновляет URL через history.pushState, не вызывая переход/ремаунт:

'use client'
 
import { useState } from 'react'
 
export function LoadMoreOrPager({ page, totalPages }: { page: number; totalPages: number }) {
  const [items, setItems] = useState<Product[]>([])
  const [currentPage, setCurrentPage] = useState(page)
 
  async function loadMore() {
    const nextPage = currentPage + 1
    const res = await fetch(`/api/products?page=${nextPage}&page_size=20`)
    const data = await res.json()
 
    setItems((prev) => [...prev, ...data.items])
    setCurrentPage(nextPage)
 
    // Обновляем URL без ремаунта страницы и без записи в историю каждой пачки —
    // именно это делает контент проверяемым (share-able) на промежуточном шаге.
    window.history.replaceState(null, '', `/products/page/${nextPage}`)
  }
 
  return (
    <>
      {/* кнопка «Показать ещё», которая по клику вызывает loadMore — дозагружает следующую страницу без перехода */}
      {currentPage < totalPages && renderLoadMoreButton(loadMore)}
      <noscript>
        <nav aria-label="Пагинация">
          {Array.from({ length: totalPages }, (_, i) => (
            <a key={i} href={`/products/page/${i + 1}`}>{i + 1}</a>
          ))}
        </nav>
      </noscript>
    </>
  )
}

renderLoadMoreButton здесь — обычная кнопка, вызывающая loadMore по клику, вынесенная в отдельный рендер-хелпер, чтобы не перегружать основной компонент.

Обратите внимание на <noscript>-блок с обычными <a href> ссылками на каждую страницу — это критично для краулеров, которые не исполняют JS вообще (часть Bing, большинство соцсетей, старые версии парсеров). Без него единственный способ обнаружить страницу 2 — это исполнить клик по кнопке, что многие краулеры просто не делают.

Второй обязательный элемент — реальные <a href="/products/page/N"> ссылки должны присутствовать в HTML до гидратации, а не только внутри onClick. Google достаточно умный, чтобы находить ссылки в исполненном DOM, но самый надёжный путь для быстрого и полного обхода — обычный статический <a>, который краулер видит без выполнения JS.

Разбивка обязанностей: кто отвечает за что

СлойОтвечает за
Backend (Page[T])page, page_size, total — единый контракт для SSR и клиента
/products/page/[n]/page.tsxServer-rendered HTML на каждый номер, self-canonical, уникальный <title>
Клиентский LoadMoreOrPagerUX дозагрузки без перезагрузки + history.replaceState для шаринга текущего состояния
<noscript>-фолбэкГарантированные <a href> для краулеров без JS

Этот же принцип «серверный список — источник правды, клиент — только способ подачи» разбирался и в посте про Next.js App Router и SEO-индексацию: там проблема была в том, что клиентский useRouter().push() вообще не создаёт серверно рендерящихся URL. Пагинация — частный случай той же ошибки: URL, который существует только в голове JS-стейта, для поисковика не существует вовсе.

Pro Tips: промптинг и выполнение

🎯 Ревьюите design-документ до кода, а не после. Промпт вида "проверь, не создаёт ли эта схема пагинации дубли контента в поиске" на этапе спеки экономит переписывание уже закодированных роутов.

🎯 Явно указывайте модели про self-referencing canonical. Модели по умолчанию часто предлагают canonical на первую страницу — это унаследованная привычка из старых SEO-гайдов 2015-2018 годов, до отказа Google от rel=next/prev. Проговаривайте это условие в промпте отдельно, не полагайтесь на то, что модель сама вспомнит про отказ от rel=next/prev.

🎯 Просите <noscript>-фолбэк сразу, а не как afterthought. Если не попросить явно, AI обычно генерирует чистый client-only Load More без запасных ссылок для краулеров — а добавлять его к уже написанному компоненту дороже, чем заложить сразу.

🎯 Проверяйте, что page в generateMetadata берётся из params, а не хардкодится. Частая галлюцинация — скопировать canonical-строку с прошлого промпта без параметризации номера страницы. Один хардкод здесь сводит на нет весь смысл self-referencing canonical.

🎯 Просите отдельный тест на edge case "page за пределами диапазона". AI хорошо генерирует happy path (страница 2 из 5), но забывает про поведение при page=999 — должен быть пустой items, а не 500-я ошибка.

Common Pitfalls

  • Canonical на первую страницу вместо self-canonical. Самая частая и самая разрушительная ошибка — реально убирает страницы 2+ из индекса, а не просто "немного путает" ранжирование.
  • Полагаться только на rel=next/prev. Тег без вреда, но Google его не учитывает с 2019 года — стройте стратегию вокруг canonical и уникальных title, а не вокруг него.
  • Load More без обновления URL вообще. Если URL не меняется при дозагрузке, у пользователя нет возможности поделиться ссылкой на "5-й экран", а у краулера — обнаружить контент за пределами первой порции.
  • Клиентская фильтрация поверх полного списка вместо серверных query-параметров. Если фильтры (поиск, категория) применяются в useMemo над уже загруженным массивом, а не как параметры запроса к API — пагинация и SEO по отфильтрованным спискам просто не работают, потому что бэкенд не знает про фильтр.
  • Забыть про page_size в дублирующихся справочных запросах. Если тот же список используется ещё и как справочник (дропдаун, статистика) с другим page_size, легко перепутать, какой из двух ответов кэшировать и какой рендерить как каноническую страницу для SEO.

Идентификация

Денис Заплахов

Исследую Пустоши рутинных задач: настраиваю AI-инструменты, создаю скиллы и тестирую нейросети на реальных кейсах.

Досье в Pip-Boy 3000
© 2026 79z.ru · Странствующий техник