Site pagination: offset, cursor, SEO, and prefetch
Note: when a catalog exceeds 10,000 items or a log table contains 500,000 rows, pagination is essential. In our practice, there was a project with 1.5 million records in PostgreSQL — COUNT executed in 4 seconds even after indexing. Users lost their position when hitting 'Back', and search bots did not index all pages. We implemented pagination for 50+ projects, from e-commerce stores to analytical dashboards. This is especially critical for projects on Laravel and Next.js, where incorrect pagination leads to Core Web Vitals degradation and lower conversion. In this article, we'll break down pagination implementation: from algorithm selection to SEO tags and prefetch.
Choosing a pagination type for an e-commerce store
The choice between offset and cursor depends on the task. Offset pagination is simple: LIMIT 20 OFFSET 40. But on large datasets, COUNT is a bottleneck. We use approximate counting via pg_class.reltuples in PostgreSQL, speeding up the query by 100x. Cursor pagination is faster but does not provide a URL for each page — a problem for SEO.
| Criteria | Offset | Cursor |
|---|---|---|
| Speed on 1M rows | 2-4 s (COUNT) | <10 ms |
| Stability under inserts | Shifting | No |
| SEO compatibility | Yes | No |
| Implementation complexity | Low | Medium |
Why prefetch matters for mobile devices?
Prefetch allows loading the next page before a click. On mobile devices with slow connections, this reduces INP by 40%. We use react-query with staleTime: 30_000 and preload data in useEffect. Example for Next.js:
useEffect(() => {
if (page < lastPage) {
queryClient.prefetchQuery({
queryKey: ['products', page + 1],
queryFn: () => fetchProducts(page + 1),
staleTime: 30_000,
});
}
}, [page]);
Performance comparison: prefetch vs no prefetch
| Scenario | LCP | INP | Transition time |
|---|---|---|---|
| No prefetch | 2.8 s | 200 ms | 600 ms |
| With prefetch | 2.1 s | 120 ms | 100 ms |
When to use cursor pagination?
Cursor pagination is suitable for real-time feeds (Twitter, comment feed) or APIs with large data volumes where order without shifts is important. We use it in projects with more than 100,000 records with frequent inserts. Cursor pagination requires a unique sort key (e.g., ID or created_at) and does not support arbitrary page jumps, but ensures stability when new records are added.
Step-by-step guide to implementing pagination on Next.js
- Define the API contract: choose offset or cursor. For offset, return
current_page,last_page,total,per_page. - Implement server logic: use
paginate()in Laravel orfind().skip().limit()in MongoDB. Cache COUNT via Redis for 5 minutes. - Develop the UI component: with ellipsis, aria-label, responsiveness.
- Synchronize with URL: via
useSearchParamsin Next.js orhistory.pushStatein vanilla JS. - Add prefetch: with React Query or
useEffectwithfetch. - Implement SEO tags:
<link rel="prev"/>and<link rel="next"/>for Yandex. Google does not use rel prev/next for ranking (Google Search Central), but they are important for Yandex. - Test: check LCP < 2.5 s, INP < 200 ms.
Server side: offset vs cursor pagination
To speed up COUNT, we use approximate counting via pg_class.reltuples in PostgreSQL or caching in Redis for 5 minutes. This reduces query time from seconds to milliseconds.
// Laravel — standard 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 pagination component
The component displays page numbers with an ellipsis for many pages. Buttons have aria-label for accessibility. Active page is highlighted. Styled with 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="Page navigation">
<ul className="flex items-center gap-1">
<li>
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
←
</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="Next page"
>
→
</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 synchronization
The page number must live in the URL — otherwise the user loses position on refresh and cannot share the link. We use useSearchParams in 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
}
What's included in our pagination work
- Audit of the current pagination scheme (SQL queries, response time)
- API contract design (offset/cursor, response format)
- Server logic implementation with COUNT caching
- UI component development with responsiveness and accessibility
- URL synchronization (Next.js, React Router, Vanilla)
- Prefetch setup for instant transitions
- SEO tag integration (rel prev/next, canonical, sitemap for pagination)
- Documentation and training for the client's team
Deadlines and cost
A basic component with API integration — from 1 day. A full package with URL synchronization, prefetch, SEO tags, responsive design, and training — from 2 to 4 days. The cost is calculated individually after analyzing your project. Development time savings on ready-made components are up to 30% compared to writing from scratch.
Contact us for a free evaluation of your project. Our engineers with years of experience will help implement pagination considering SEO and performance. We guarantee LCP < 2.5 s and correct indexing in Yandex. Order pagination implementation with a Core Web Vitals guarantee. Get an engineer consultation and a prototype in 1 day.







