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#

  1. Choose pagination type: Offset for page navigation, cursor for infinite scroll
  2. Implement helper functions: Use the pagination utilities in your queries
  3. Create API endpoints: Return paginated data with metadata
  4. Build UI components: Add navigation or infinite scroll to your pages
  5. Handle edge cases: Empty states, loading states, and errors

Best Practices#

  1. Limit page size - Cap maximum items per page (e.g., 100)
  2. Use cursor pagination for large datasets - Better performance than offset
  3. Include pagination metadata - Return total count, has next/prev
  4. Validate page parameters - Ensure positive integers, reasonable limits
  5. Preserve filters in pagination - Maintain query params when navigating
  6. Handle empty states - Show appropriate UI when no results
  7. Optimize count queries - Consider approximate counts for very large tables