SupaLaunch is a SaaS boilerplate built using Supabase and Next.js. It includes authentication, Stripe payments, Postgres database, 20+ TailwindCSS themes, emails, OpenAI API streaming, file storage and more.
When building data-rich applications, efficiently displaying large datasets is critical for performance and user experience. Pagination is an essential technique that allows users to navigate through chunks of data rather than loading everything at once. In this comprehensive guide, we'll explore how to implement pagination in a Next.js application using Supabase as the backend.
Before we begin, make sure you have:
First, install the Supabase client library:
npm install @supabase/supabase-js
# or
yarn add @supabase/supabase-js
If you're using Next.js with authentication:
npm install @supabase/auth-helpers-nextjs
# or
yarn add @supabase/auth-helpers-nextjs
Create a .env.local
file in your project root:
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
For a Next.js Pages Router project:
// lib/supabaseClient.js
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseKey)
For a Next.js App Router project:
// lib/supabase.js
import { createClient } from '@supabase/supabase-js'
export function createSupabaseClient() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
}
Offset-based pagination is the most straightforward approach, using limit
and offset
parameters to retrieve a specific range of records.
// components/OffsetPagination.jsx
'use client' // If using App Router
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabaseClient' // Adjust path as needed
export default function OffsetPagination() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(0)
const pageSize = 10
const fetchPosts = async (page) => {
try {
setLoading(true)
// Calculate the offset
const from = (page - 1) * pageSize
const to = from + pageSize - 1
// Fetch data with range
const { data, error, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to)
if (error) throw error
setPosts(data)
// Calculate total pages
if (count) {
setTotalPages(Math.ceil(count / pageSize))
}
} catch (error) {
console.error('Error fetching posts:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPosts(currentPage)
}, [currentPage])
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1)
}
}
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1)
}
}
return (
<div className="pagination-container">
{loading ? (
<p>Loading posts...</p>
) : (
<>
<div className="posts-grid">
{posts.map((post) => (
<div key={post.id} className="post-card">
<h3>{post.title}</h3>
<p>{post.excerpt}</p>
</div>
))}
</div>
<div className="pagination-controls">
<button
onClick={handlePrevPage}
disabled={currentPage === 1}
>
Previous
</button>
<span>{currentPage} of {totalPages}</span>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
</>
)}
</div>
)
}
For Next.js App Router, you can fetch data in a server component:
// app/posts/page.jsx
import { createSupabaseClient } from '@/lib/supabase'
import PaginationControls from '@/components/PaginationControls'
import PostsList from '@/components/PostsList'
export default async function PostsPage({ searchParams }) {
const page = Number(searchParams.page) || 1
const pageSize = 10
const supabase = createSupabaseClient()
const from = (page - 1) * pageSize
const to = from + pageSize - 1
const { data: posts, count } = await supabase
.from('posts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to)
const totalPages = Math.ceil(count / pageSize)
return (
<div>
<h1>Blog Posts</h1>
<PostsList posts={posts} />
<PaginationControls currentPage={page} totalPages={totalPages} />
</div>
)
}
// components/PaginationControls.jsx
'use client'
import { useRouter, usePathname } from 'next/navigation'
export default function PaginationControls({ currentPage, totalPages }) {
const router = useRouter()
const pathname = usePathname()
const handlePageChange = (page) => {
router.push(`${pathname}?page=${page}`)
}
return (
<div className="pagination-controls">
<button
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage <= 1}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
>
Next
</button>
</div>
)
}
Cursor-based pagination is more efficient for large datasets as it doesn't require counting all rows. It uses a unique identifier from the last item as a cursor to fetch the next set.
// components/CursorPagination.jsx
'use client'
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabaseClient'
export default function CursorPagination() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [nextCursor, setNextCursor] = useState(null)
const [prevCursors, setPrevCursors] = useState([])
const pageSize = 10
const fetchItems = async (cursor = null, direction = 'next') => {
try {
setLoading(true)
let query = supabase
.from('items')
.select('*')
.order('id', { ascending: direction === 'next' })
.limit(pageSize)
if (cursor) {
if (direction === 'next') {
query = query.gt('id', cursor)
} else {
query = query.lt('id', cursor)
}
}
const { data, error } = await query
if (error) throw error
if (direction === 'prev') {
data.reverse() // Reverse to maintain chronological order
}
setItems(data)
// Set cursor for next page
if (data.length === pageSize) {
setNextCursor(data[data.length - 1].id)
} else {
setNextCursor(null)
}
} catch (error) {
console.error('Error fetching items:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchItems()
}, [])
const handleNextPage = () => {
if (nextCursor) {
setPrevCursors([...prevCursors, items[0].id])
fetchItems(nextCursor, 'next')
}
}
const handlePrevPage = () => {
if (prevCursors.length > 0) {
const cursor = prevCursors.pop()
setPrevCursors([...prevCursors])
fetchItems(cursor, 'prev')
}
}
return (
<div>
{loading ? (
<p>Loading...</p>
) : (
<>
<div className="items-list">
{items.map((item) => (
<div key={item.id} className="item-card">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
))}
</div>
<div className="pagination-controls">
<button
onClick={handlePrevPage}
disabled={prevCursors.length === 0}
>
Previous
</button>
<button
onClick={handleNextPage}
disabled={!nextCursor}
>
Next
</button>
</div>
</>
)}
</div>
)
}
Infinite scroll provides a more modern user experience by loading more content as the user scrolls down the page.
// components/InfiniteScroll.jsx
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { supabase } from '../lib/supabaseClient'
export default function InfiniteScroll() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [cursor, setCursor] = useState(null)
const pageSize = 10
const observer = useRef()
const lastItemRef = useCallback(node => {
if (loading) return
if (observer.current) observer.current.disconnect()
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
loadMoreItems()
}
})
if (node) observer.current.observe(node)
}, [loading, hasMore])
const fetchItems = async (initialFetch = false) => {
try {
setLoading(true)
let query = supabase
.from('items')
.select('*')
.order('created_at', { ascending: false })
.limit(pageSize)
if (cursor && !initialFetch) {
query = query.lt('created_at', cursor)
}
const { data, error } = await query
if (error) throw error
if (initialFetch) {
setItems(data)
} else {
setItems(prev => [...prev, ...data])
}
if (data.length < pageSize) {
setHasMore(false)
} else {
setCursor(data[data.length - 1].created_at)
}
} catch (error) {
console.error('Error fetching items:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchItems(true)
}, [])
const loadMoreItems = () => {
if (!loading && hasMore) {
fetchItems()
}
}
return (
<div className="infinite-scroll-container">
<div className="items-grid">
{items.map((item, index) => {
if (items.length === index + 1) {
return (
<div
ref={lastItemRef}
key={item.id}
className="item-card"
>
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
)
} else {
return (
<div key={item.id} className="item-card">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
)
}
})}
</div>
{loading && <p className="loading-indicator">Loading more items...</p>}
{!hasMore && <p className="no-more-items">No more items to load</p>}
</div>
)
}
Combining pagination with search functionality and filters adds complexity but enhances user experience.
// components/SearchablePagination.jsx
'use client'
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabaseClient'
export default function SearchablePagination() {
const [products, setProducts] = useState([])
const [loading, setLoading] = useState(false)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(0)
const [searchTerm, setSearchTerm] = useState('')
const [category, setCategory] = useState('all')
const pageSize = 10
const fetchProducts = async (page) => {
try {
setLoading(true)
const from = (page - 1) * pageSize
const to = from + pageSize - 1
let query = supabase
.from('products')
.select('*', { count: 'exact' })
// Apply search filter if search term exists
if (searchTerm) {
query = query.ilike('name', `%${searchTerm}%`)
}
// Apply category filter if not 'all'
if (category !== 'all') {
query = query.eq('category', category)
}
// Add sorting and pagination
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(from, to)
if (error) throw error
setProducts(data)
if (count) {
setTotalPages(Math.ceil(count / pageSize))
}
} catch (error) {
console.error('Error fetching products:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
// Reset to first page when search or filter changes
setCurrentPage(1)
fetchProducts(1)
}, [searchTerm, category])
useEffect(() => {
fetchProducts(currentPage)
}, [currentPage])
const handleSearch = (e) => {
e.preventDefault()
// Search is already triggered by the useEffect
}
const handleNextPage = () => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1)
}
}
const handlePrevPage = () => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1)
}
}
return (
<div className="searchable-pagination">
<div className="search-filters">
<form onSubmit={handleSearch}>
<input
type="text"
placeholder="Search products..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="all">All Categories</option>
<option value="electronics">Electronics</option>
<option value="clothing">Clothing</option>
<option value="books">Books</option>
</select>
<button type="submit">Search</button>
</form>
</div>
{loading ? (
<p>Loading products...</p>
) : (
<>
<div className="products-grid">
{products.length > 0 ? (
products.map((product) => (
<div key={product.id} className="product-card">
<h3>{product.name}</h3>
<p>{product.description}</p>
<p className="price">${product.price}</p>
<span className="category">{product.category}</span>
</div>
))
) : (
<p>No products found matching your criteria.</p>
)}
</div>
{totalPages > 0 && (
<div className="pagination-controls">
<button
onClick={handlePrevPage}
disabled={currentPage === 1}
>
Previous
</button>
<span>{currentPage} of {totalPages}</span>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
)}
</>
)}
</div>
)
}
For applications that need SEO benefits or server-side rendering, implement pagination with API routes:
// pages/api/posts.js
import { supabase } from '../../lib/supabaseClient'
export default async function handler(req, res) {
const { page = 1, size = 10, category } = req.query
const pageSize = parseInt(size)
const currentPage = parseInt(page)
const from = (currentPage - 1) * pageSize
const to = from + pageSize - 1
try {
let query = supabase
.from('posts')
.select('*', { count: 'exact' })
if (category) {
query = query.eq('category', category)
}
const { data, error, count } = await query
.order('created_at', { ascending: false })
.range(from, to)
if (error) throw error
res.status(200).json({
posts: data,
totalPages: Math.ceil(count / pageSize),
currentPage: currentPage
})
} catch (error) {
res.status(500).json({ error: error.message })
}
}
Then consume it in your page component:
// pages/blog.js
import { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
export default function BlogPage() {
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [totalPages, setTotalPages] = useState(0)
const router = useRouter()
const { page = 1, category } = router.query
useEffect(() => {
if (!router.isReady) return
const fetchPosts = async () => {
try {
setLoading(true)
const queryParams = new URLSearchParams({
page: page,
size: 10
})
if (category) {
queryParams.append('category', category)
}
const response = await fetch(`/api/posts?${queryParams}`)
const data = await response.json()
setPosts(data.posts)
setTotalPages(data.totalPages)
} catch (error) {
console.error('Error fetching posts:', error)
} finally {
setLoading(false)
}
}
fetchPosts()
}, [router.isReady, page, category])
const handlePageChange = (newPage) => {
const query = { page: newPage }
if (category) {
query.category = category
}
router.push({
pathname: router.pathname,
query: query
})
}
return (
<div className="blog-container">
<h1>Blog Posts</h1>
{loading ? (
<p>Loading posts...</p>
) : (
<>
<div className="posts-grid">
{posts.map((post) => (
<div key={post.id} className="post-card">
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
<a href={`/blog/${post.slug}`}>Read more</a>
</div>
))}
</div>
<div className="pagination-controls">
<button
onClick={() => handlePageChange(Number(page) - 1)}
disabled={Number(page) <= 1}
>
Previous
</button>
<span>Page {page} of {totalPages}</span>
<button
onClick={() => handlePageChange(Number(page) + 1)}
disabled={Number(page) >= totalPages}
>
Next
</button>
</div>
</>
)}
</div>
)
}
To ensure your pagination implementation is as efficient as possible, consider these optimization strategies:
Make sure the columns you're ordering and filtering by are properly indexed in your Supabase database.
CREATE INDEX idx_posts_created_at ON posts (created_at DESC);
Instead of selecting all fields with *
, specify only the columns you need:
const { data, error } = await supabase
.from('posts')
.select('id, title, excerpt, created_at')
.range(from, to)
For very large tables, getting an exact count can be slow. Consider using count estimates:
// Get an estimate for better performance
const { count } = await supabase
.from('posts')
.select('id', { count: 'estimated' })
// Only get exact count for first page
if (currentPage === 1) {
const { count: exactCount } = await supabase
.from('posts')
.select('id', { count: 'exact' })
setTotalItems(exactCount)
}
Cache previously fetched pages to reduce database queries:
const cachedPages = useRef({})
const fetchPage = async (page) => {
// Check if we already have this page cached
if (cachedPages.current[page]) {
setPosts(cachedPages.current[page])
return
}
// Fetch from Supabase
const { data } = await supabase
.from('posts')
.select('*')
.range((page - 1) * pageSize, page * pageSize - 1)
// Cache the results
cachedPages.current[page] = data
setPosts(data)
}
When dealing with very large offsets, performance can degrade significantly. Switching to cursor-based pagination is the best solution:
// Instead of:
.range(9990, 9999) // Slow for large offsets
// Use cursor-based approach:
.gt('id', lastSeenId)
.limit(10)
If data changes while a user is paginating, they might see duplicates or miss items. Solutions include:
Ensure your pagination is keyboard-accessible and screen-reader friendly:
<nav aria-label="Pagination">
<ul className="pagination">
<li>
<button
onClick={handlePrevPage}
disabled={currentPage === 1}
aria-label="Go to previous page"
>
Previous
</button>
</li>
<li aria-current="page">
Page {currentPage} of {totalPages}
</li>
<li>
<button
onClick={handleNextPage}
disabled={currentPage === totalPages}
aria-label="Go to next page"
>
Next
</button>
</li>
</ul>
</nav>
Implementing pagination in your Next.js application with Supabase can be approached in different ways, each with its own benefits:
The method you choose depends on your specific requirements, data size, and user experience goals. By following the examples and best practices in this guide, you'll be well-equipped to implement efficient pagination in your Next.js and Supabase projects.
Remember that proper indexing and query optimization are just as important as the pagination implementation itself. With the right approach, you can provide a smooth, fast experience for your users while efficiently managing your application's resources.
Happy coding!