Пагінація на сайті: 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 день.







