Pagination Pattern
Implement efficient data pagination with offset-based and cursor-based strategies, including UI components and API integration.
Overview#
Pagination is essential for handling large datasets efficiently. This pattern covers both offset-based pagination (simple, page numbers) and cursor-based pagination (performant, infinite scroll).
When to use:
- Displaying large lists of data
- Implementing search results
- Building data tables
- Creating infinite scroll interfaces
Key features:
- Offset-based pagination for page navigation
- Cursor-based pagination for infinite scroll
- Type-safe pagination helpers
- Reusable UI components
Code Example#
Offset Pagination#
1// lib/pagination.ts
2interface PaginationParams {
3 page?: number
4 limit?: number
5}
6
7interface PaginatedResult<T> {
8 data: T[]
9 pagination: {
10 page: number
11 limit: number
12 total: number
13 totalPages: number
14 hasNext: boolean
15 hasPrev: boolean
16 }
17}
18
19export async function paginate<T>(
20 model: any,
21 params: PaginationParams,
22 options?: {
23 where?: object
24 orderBy?: object
25 include?: object
26 }
27): Promise<PaginatedResult<T>> {
28 const page = Math.max(1, params.page ?? 1)
29 const limit = Math.min(100, Math.max(1, params.limit ?? 10))
30 const skip = (page - 1) * limit
31
32 const [data, total] = await Promise.all([
33 model.findMany({
34 ...options,
35 skip,
36 take: limit
37 }),
38 model.count({ where: options?.where })
39 ])
40
41 const totalPages = Math.ceil(total / limit)
42
43 return {
44 data,
45 pagination: {
46 page,
47 limit,
48 total,
49 totalPages,
50 hasNext: page < totalPages,
51 hasPrev: page > 1
52 }
53 }
54}
55
56// Usage
57const result = await paginate(prisma.post, { page: 2, limit: 10 }, {
58 where: { published: true },
59 orderBy: { createdAt: 'desc' }
60})Cursor Pagination#
1// lib/cursor-pagination.ts
2interface CursorPaginationParams {
3 cursor?: string
4 limit?: number
5}
6
7interface CursorPaginatedResult<T> {
8 data: T[]
9 nextCursor: string | null
10 hasMore: boolean
11}
12
13export async function cursorPaginate<T extends { id: string }>(
14 model: any,
15 params: CursorPaginationParams,
16 options?: {
17 where?: object
18 orderBy?: object
19 include?: object
20 }
21): Promise<CursorPaginatedResult<T>> {
22 const limit = Math.min(100, Math.max(1, params.limit ?? 10))
23
24 const data = await model.findMany({
25 ...options,
26 take: limit + 1,
27 ...(params.cursor && {
28 cursor: { id: params.cursor },
29 skip: 1
30 })
31 })
32
33 const hasMore = data.length > limit
34 const items = hasMore ? data.slice(0, -1) : data
35
36 return {
37 data: items,
38 nextCursor: hasMore ? items[items.length - 1].id : null,
39 hasMore
40 }
41}
42
43// Usage
44const result = await cursorPaginate(prisma.post, {
45 cursor: lastPostId,
46 limit: 20
47}, {
48 orderBy: { createdAt: 'desc' }
49})API Route with Pagination#
1// app/api/posts/route.ts
2import { NextRequest, NextResponse } from 'next/server'
3import { paginate } from '@/lib/pagination'
4import { prisma } from '@/lib/db'
5
6export async function GET(request: NextRequest) {
7 const searchParams = request.nextUrl.searchParams
8
9 const page = parseInt(searchParams.get('page') ?? '1')
10 const limit = parseInt(searchParams.get('limit') ?? '10')
11
12 const result = await paginate(prisma.post, { page, limit }, {
13 where: { published: true },
14 orderBy: { createdAt: 'desc' },
15 include: { author: true }
16 })
17
18 return NextResponse.json(result)
19}Pagination Component#
1// components/Pagination.tsx
2'use client'
3
4import Link from 'next/link'
5import { useSearchParams } from 'next/navigation'
6import { ChevronLeft, ChevronRight } from 'lucide-react'
7
8interface Props {
9 totalPages: number
10 basePath: string
11}
12
13export function Pagination({ totalPages, basePath }: Props) {
14 const searchParams = useSearchParams()
15 const currentPage = parseInt(searchParams.get('page') ?? '1')
16
17 const createPageUrl = (page: number) => {
18 const params = new URLSearchParams(searchParams)
19 params.set('page', page.toString())
20 return `${basePath}?${params.toString()}`
21 }
22
23 const pages = getPageNumbers(currentPage, totalPages)
24
25 return (
26 <nav className="flex items-center gap-2">
27 <Link
28 href={createPageUrl(currentPage - 1)}
29 className={`p-2 ${currentPage <= 1 ? 'pointer-events-none opacity-50' : ''}`}
30 >
31 <ChevronLeft className="h-5 w-5" />
32 </Link>
33
34 {pages.map((page, i) => (
35 page === '...' ? (
36 <span key={i} className="px-2">...</span>
37 ) : (
38 <Link
39 key={i}
40 href={createPageUrl(page as number)}
41 className={`rounded px-3 py-1 ${
42 currentPage === page ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'
43 }`}
44 >
45 {page}
46 </Link>
47 )
48 ))}
49
50 <Link
51 href={createPageUrl(currentPage + 1)}
52 className={`p-2 ${currentPage >= totalPages ? 'pointer-events-none opacity-50' : ''}`}
53 >
54 <ChevronRight className="h-5 w-5" />
55 </Link>
56 </nav>
57 )
58}
59
60function getPageNumbers(current: number, total: number): (number | '...')[] {
61 if (total <= 7) {
62 return Array.from({ length: total }, (_, i) => i + 1)
63 }
64
65 if (current <= 3) {
66 return [1, 2, 3, 4, 5, '...', total]
67 }
68
69 if (current >= total - 2) {
70 return [1, '...', total - 4, total - 3, total - 2, total - 1, total]
71 }
72
73 return [1, '...', current - 1, current, current + 1, '...', total]
74}Infinite Scroll Component#
1// components/InfiniteList.tsx
2'use client'
3
4import { useInfiniteQuery } from '@tanstack/react-query'
5import { useInView } from 'react-intersection-observer'
6import { useEffect } from 'react'
7
8interface Props<T> {
9 queryKey: string[]
10 fetchFn: (cursor?: string) => Promise<{
11 data: T[]
12 nextCursor: string | null
13 }>
14 renderItem: (item: T) => React.ReactNode
15}
16
17export function InfiniteList<T extends { id: string }>({
18 queryKey,
19 fetchFn,
20 renderItem
21}: Props<T>) {
22 const { ref, inView } = useInView()
23
24 const {
25 data,
26 fetchNextPage,
27 hasNextPage,
28 isFetchingNextPage,
29 isLoading
30 } = useInfiniteQuery({
31 queryKey,
32 queryFn: ({ pageParam }) => fetchFn(pageParam),
33 initialPageParam: undefined as string | undefined,
34 getNextPageParam: (lastPage) => lastPage.nextCursor
35 })
36
37 useEffect(() => {
38 if (inView && hasNextPage && !isFetchingNextPage) {
39 fetchNextPage()
40 }
41 }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage])
42
43 if (isLoading) {
44 return <div>Loading...</div>
45 }
46
47 const items = data?.pages.flatMap(page => page.data) ?? []
48
49 return (
50 <div>
51 {items.map(item => (
52 <div key={item.id}>{renderItem(item)}</div>
53 ))}
54
55 <div ref={ref} className="h-10">
56 {isFetchingNextPage && <div>Loading more...</div>}
57 </div>
58 </div>
59 )
60}Usage Instructions#
- Choose pagination type: Offset for page navigation, cursor for infinite scroll
- Implement helper functions: Use the pagination utilities in your queries
- Create API endpoints: Return paginated data with metadata
- Build UI components: Add navigation or infinite scroll to your pages
- Handle edge cases: Empty states, loading states, and errors
Best Practices#
- Limit page size - Cap maximum items per page (e.g., 100)
- Use cursor pagination for large datasets - Better performance than offset
- Include pagination metadata - Return total count, has next/prev
- Validate page parameters - Ensure positive integers, reasonable limits
- Preserve filters in pagination - Maintain query params when navigating
- Handle empty states - Show appropriate UI when no results
- Optimize count queries - Consider approximate counts for very large tables
Related Patterns#
- Filtering - Add filters to paginated data
- Route Handler - API endpoint implementation
- React Query - Client-side data fetching
- Tables - Data table components