Кнопка «Показать ещё» удобна пользователю и вредна для 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.tsx | Server-rendered HTML на каждый номер, self-canonical, уникальный <title> |
Клиентский LoadMoreOrPager | UX дозагрузки без перезагрузки + 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.