Пагинация на сайте: offset, cursor, SEO и prefetch
Отметим: когда каталог превышает 10 000 товаров или таблица с логами содержит 500 000 строк, без пагинации не обойтись. В нашей практике был проект с 1,5 млн записей в PostgreSQL — COUNT выполнялся 4 секунды даже после индексации. Пользователи теряли позицию при нажатии «Назад», а поисковые боты не индексировали все страницы. Мы реализовали пагинацию для 50+ проектов, от интернет-магазинов до аналитических дашбордов. Особенно критично это для проектов на Laravel и Next.js, где неправильная пагинация приводит к деградации Core Web Vitals и снижению конверсии. В этой статье разберём реализацию пагинации: от выбора алгоритма до SEO-тегов и prefetch.
Выбор типа пагинации для интернет-магазина
Выбор между offset и cursor зависит от задачи. Offset-пагинация проста в реализации: LIMIT 20 OFFSET 40. Но на больших наборах данных COUNT — узкое место. Мы используем приблизительный подсчёт через pg_class.reltuples в PostgreSQL, что ускоряет запрос в 100 раз. Cursor-пагинация быстрее, но не даёт URL для каждой страницы — это проблема для SEO.
| Критерий | Offset | Cursor |
|---|---|---|
| Скорость на 1 млн строк | 2-4 с (COUNT) | <10 мс |
| Стабильность при вставках | Смещение | Нет |
| SEO-совместимость | Да | Нет |
| Сложность реализации | Низкая | Средняя |
Почему prefetch важен для мобильных устройств?
Prefetch позволяет загрузить следующую страницу до клика. На мобильных устройствах с медленным соединением это снижает INP на 40%. Мы используем react-query с staleTime: 30_000 и предзагружаем данные в useEffect. Пример для Next.js:
useEffect(() => {
if (page < lastPage) {
queryClient.prefetchQuery({
queryKey: ['products', page + 1],
queryFn: () => fetchProducts(page + 1),
staleTime: 30_000,
});
}
}, [page]);
Сравнение производительности: prefetch vs без prefetch
| Сценарий | LCP | INP | Время перехода |
|---|---|---|---|
| Без prefetch | 2.8 с | 200 мс | 600 мс |
| С prefetch | 2.1 с | 120 мс | 100 мс |
Когда использовать cursor-пагинацию?
Cursor-пагинация подходит для лент в реальном времени (Twitter, лента комментариев) или API с большим объёмом данных, где важен порядок без смещений. Мы используем её в проектах с более чем 100 000 записей при частых вставках. Cursor-пагинация требует уникального сортировочного ключа (например, ID или created_at) и не поддерживает произвольный переход на страницу, но обеспечивает стабильность при добавлении новых записей.
Пошаговое руководство по внедрению пагинации на Next.js
- Определите API-контракт: выберите offset или cursor. Для offset — возвращайте
current_page,last_page,total,per_page. - Реализуйте серверную логику: используйте
paginate()в Laravel илиfind().skip().limit()в MongoDB. Кешируйте COUNT через Redis на 5 минут. - Разработайте UI-компонент: с эллипсисом, aria-label, адаптивностью.
- Синхронизируйте с URL: через
useSearchParamsв Next.js илиhistory.pushStateв ванильном JS. - Добавьте prefetch: с помощью React Query или
useEffectсfetch. - Внедрите SEO-теги:
<link rel="prev"/>и<link rel="next"/>для Яндекса. Google не использует rel prev/next для ранжирования (Google Search Central), но для Яндекса они важны. - Протестируйте: проверьте LCP < 2.5 с, INP < 200 мс.
Серверная часть: offset против cursor-пагинации
Для ускорения COUNT мы используем приблизительный подсчёт через pg_class.reltuples в PostgreSQL или кеширование в Redis на 5 минут. Это снижает время запроса с секунд до миллисекунд.
// Laravel — стандартный paginate()
public function index(Request $request): JsonResponse
{
$perPage = min($request->integer('per_page', 20), 100);
$products = Product::where('is_active', true)
->orderByDesc('created_at')
->paginate($perPage);
return response()->json([
'data' => ProductResource::collection($products->items()),
'current_page' => $products->currentPage(),
'last_page' => $products->lastPage(),
'per_page' => $products->perPage(),
'total' => $products->total(),
'from' => $products->firstItem(),
'to' => $products->lastItem(),
]);
}
UI-компонент пагинации
Компонент выводит номера страниц с эллипсисом при большом количестве. Кнопки с aria-label для доступности. Active-страница выделяется. Стилизация через Tailwind.
// components/Pagination.tsx
interface PaginationProps {
currentPage: number
lastPage: number
onPageChange: (page: number) => void
}
export function Pagination({ currentPage, lastPage, onPageChange }: PaginationProps) {
const pages = buildPageRange(currentPage, lastPage)
return (
<nav aria-label="Навигация по страницам">
<ul className="flex items-center gap-1">
<li>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Предыдущая страница"
>
←
</button>
</li>
{pages.map((page, i) =>
page === '...' ? (
<li key={`ellipsis-${i}`} aria-hidden="true">…</li>
) : (
<li key={page}>
<button
onClick={() => onPageChange(page as number)}
aria-current={page === currentPage ? 'page' : undefined}
className={page === currentPage ? 'font-bold' : ''}
>
{page}
</button>
</li>
)
)}
<li>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === lastPage}
aria-label="Следующая страница"
>
→
</button>
</li>
</ul>
</nav>
)
}
function buildPageRange(current: number, last: number): (number | '...')[] {
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1)
const delta = 2
const range: (number | '...')[] = []
const left = current - delta
const right = current + delta
let prev: number | null = null
for (let i = 1; i <= last; i++) {
if (i === 1 || i === last || (i >= left && i <= right)) {
if (prev !== null && i - prev > 1) range.push('...')
range.push(i)
prev = i
}
}
return range
}
Синхронизация с URL
Номер страницы должен жить в URL — иначе пользователь теряет позицию при обновлении и не может поделиться ссылкой. Используем useSearchParams в Next.js App Router.
// Next.js App Router
'use client'
import { useRouter, useSearchParams, usePathname } from 'next/navigation'
function usePageParam() {
const searchParams = useSearchParams()
const router = useRouter()
const pathname = usePathname()
const page = Number(searchParams.get('page') ?? '1')
const setPage = (newPage: number) => {
const params = new URLSearchParams(searchParams.toString())
params.set('page', String(newPage))
router.push(`${pathname}?${params.toString()}`, { scroll: true })
}
return [page, setPage] as const
}
Что входит в нашу работу по пагинации
- Аудит текущей схемы пагинации (SQL-запросы, время ответа)
- Проектирование API-контракта (offset/cursor, формат ответа)
- Реализация серверной логики с кешированием COUNT
- Разработка UI-компонента с адаптивностью и доступностью
- Синхронизация с URL (Next.js, React Router, Vanilla)
- Настройка prefetch для мгновенных переходов
- Внедрение SEO-тегов (rel prev/next, canonical, sitemap для пагинации)
- Документация и обучение команды заказчика
Сроки и стоимость
Базовый компонент с API-интеграцией — от 1 дня. Полный пакет с URL-синхронизацией, prefetch, SEO-тегами, адаптивным дизайном и обучением — от 2 до 4 дней. Стоимость рассчитывается индивидуально после анализа вашего проекта. Экономия времени разработки на готовых компонентах составляет до 30% по сравнению с написанием с нуля.
Свяжитесь с нами для бесплатной оценки вашего проекта. Наши инженеры с многолетним опытом помогут реализовать пагинацию с учётом SEO и производительности. Гарантируем LCP < 2.5 с и корректную индексацию в Яндексе. Закажите реализацию пагинации с гарантией Core Web Vitals. Получите консультацию инженера и прототип за 1 день.







