Реалізація пагінації на веб-сайті
Пагінація ділить великі набори даних на сторінки фіксованого розміру. Це базовий елемент будь-якого сайту з каталогом, таблицею або лентою публікацій. Реалізація включає серверну частину (запити з LIMIT/OFFSET), API-контракт та UI-компонент навігації.
Серверна частина
// 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(),
]);
}
Запит до API: GET /api/products?page=3&per_page=20
Під капотом Laravel виконує два SQL-запити: COUNT(*) для total і SELECT ... LIMIT 20 OFFSET 40. На таблицях з 500k+ рядків COUNT може гальмувати — вирішується кешуванням total або переходом на приблизний підсумок через pg_class.reltuples.
UI-компонент пагінації
// 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>
)
}
// Виробляє масив на кшталт [1, 2, '...', 7, 8, 9, '...', 20]
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 — інакше користувачі втрачають позицію при оновленні сторінки та не можуть поділитися посиланням.
// 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
}
// Vanilla React — history API
function setPageInUrl(page: number) {
const url = new URL(window.location.href)
url.searchParams.set('page', String(page))
window.history.pushState({}, '', url)
}
Prefetch наступної сторінки
Щоб переходи ощущались миттєвими, завантажуємо дані наступної сторінки заздалегідь:
import { useQueryClient } from '@tanstack/react-query'
function useProductsPrefetch(currentPage: number) {
const queryClient = useQueryClient()
useEffect(() => {
if (currentPage < lastPage) {
queryClient.prefetchQuery({
queryKey: ['products', currentPage + 1],
queryFn: () => fetchProducts(currentPage + 1),
staleTime: 30_000,
})
}
}, [currentPage])
}
SEO
Для пошукових ботів сторінки з пагінацією повинні бути правильно пов'язані:
<!-- На сторінці /catalog?page=3 -->
<link rel="prev" href="/catalog?page=2" />
<link rel="next" href="/catalog?page=4" />
<link rel="canonical" href="/catalog?page=3" />
Google офіційно не використовує rel=prev/next з 2019 року, але Яндекс — так. У Laravel Blade або Next.js Metadata API ці теги варто виводити.
Часові рамки
Базовий компонент з API-інтеграцією — 1 день. З синхронізацією URL, prefetch, SEO-тегами та адаптивним відображенням — 2 дні.







