Supabase supports two pagination methods. Offset pagination uses .range(from, to) to skip and limit rows — simple to implement but slow on large tables. Keyset (cursor) pagination uses .gt() or .lt() on an indexed column to fetch the next page — fast at any depth. For most applications, start with offset pagination and switch to keyset pagination when tables exceed 10,000 rows or when deep page access causes noticeable latency.
Implementing Efficient Pagination with Supabase
Loading every row from a table into the browser is impractical once your data grows beyond a few hundred records. Pagination lets you fetch data in pages, showing a manageable number of rows at a time. This tutorial covers both offset-based and keyset-based pagination with the Supabase JavaScript client, including how to build pagination UI and handle edge cases.
Prerequisites
- A Supabase project with a table containing data to paginate
- @supabase/supabase-js v2+ installed in your project
- Basic understanding of SQL ORDER BY and LIMIT/OFFSET concepts
Step-by-step guide
Implement offset pagination with .range()
Implement offset pagination with .range()
The simplest pagination method uses the .range() modifier to specify which rows to return. Pass a zero-based start index and end index (inclusive). For example, .range(0, 9) returns the first 10 rows, .range(10, 19) returns the next 10, and so on. Always combine .range() with .order() to ensure consistent page ordering.
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 process.env.NEXT_PUBLIC_SUPABASE_URL!,5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!6)78const PAGE_SIZE = 10910async function fetchPage(page: number) {11 const from = page * PAGE_SIZE12 const to = from + PAGE_SIZE - 11314 const { data, error, count } = await supabase15 .from('products')16 .select('*', { count: 'exact' }) // Returns total count17 .order('created_at', { ascending: false })18 .range(from, to)1920 return { data, error, count }21}2223// Page 0: rows 0-924// Page 1: rows 10-1925// Page 2: rows 20-29Expected result: Each call returns exactly PAGE_SIZE rows (or fewer on the last page) plus the total count for building page navigation.
Get total row count for pagination UI
Get total row count for pagination UI
To show page numbers or a 'Page X of Y' indicator, you need the total number of matching rows. The { count: 'exact' } option on .select() returns this without fetching all the data. For filtered queries, the count reflects only rows matching the filter. You can also use { count: 'planned' } for a faster but approximate count on very large tables.
1// Exact count (accurate but slower on very large tables)2const { data, count } = await supabase3 .from('products')4 .select('*', { count: 'exact' })5 .eq('category', 'electronics')6 .order('created_at', { ascending: false })7 .range(0, 9)89const totalPages = Math.ceil((count ?? 0) / PAGE_SIZE)10console.log(`Page 1 of ${totalPages} (${count} total items)`)1112// Head-only count (no data returned, just the count)13const { count: totalRows } = await supabase14 .from('products')15 .select('*', { count: 'exact', head: true })16 .eq('category', 'electronics')Expected result: The count value contains the total number of matching rows, enabling you to build page navigation controls.
Implement keyset pagination for large datasets
Implement keyset pagination for large datasets
Offset pagination slows down on large tables because the database must scan and discard all rows before the offset. Keyset (cursor) pagination avoids this by using a WHERE clause on an indexed column to jump directly to the next page. After fetching a page, pass the last row's cursor value to get the next page. This stays fast regardless of how deep into the dataset you go.
1async function fetchNextPage(cursor?: string) {2 let query = supabase3 .from('products')4 .select('*')5 .order('created_at', { ascending: false })6 .limit(PAGE_SIZE)78 if (cursor) {9 // Fetch rows with created_at before the cursor10 query = query.lt('created_at', cursor)11 }1213 const { data, error } = await query1415 // The next cursor is the last row's created_at16 const nextCursor = data && data.length === PAGE_SIZE17 ? data[data.length - 1].created_at18 : null1920 return { data, error, nextCursor }21}2223// Usage:24// First page: fetchNextPage()25// Next page: fetchNextPage('2024-03-15T10:30:00Z')26// No more pages when nextCursor is nullExpected result: Each page loads in constant time regardless of depth. The nextCursor value is null when there are no more pages.
Combine pagination with filters and sorting
Combine pagination with filters and sorting
Real applications need pagination combined with search filters, category filters, and custom sort orders. Chain .eq(), .ilike(), or other filter methods before .range() or cursor logic. When using keyset pagination with filters, the cursor column and the sort column must match. If you allow users to change the sort column, you need to adjust the cursor comparison operator accordingly.
1async function fetchFilteredPage(2 page: number,3 category?: string,4 search?: string5) {6 const from = page * PAGE_SIZE7 const to = from + PAGE_SIZE - 189 let query = supabase10 .from('products')11 .select('*', { count: 'exact' })12 .order('price', { ascending: true })13 .range(from, to)1415 // Apply optional filters16 if (category) {17 query = query.eq('category', category)18 }19 if (search) {20 query = query.ilike('name', `%${search}%`)21 }2223 const { data, error, count } = await query24 return {25 data,26 error,27 totalPages: Math.ceil((count ?? 0) / PAGE_SIZE),28 }29}Expected result: Filtered results are paginated correctly, with the total count reflecting only the matching rows.
Build a pagination component
Build a pagination component
Wrap the pagination logic in a reusable React component or hook. Track the current page in state, fetch data when the page changes, and render navigation controls. Show previous/next buttons, page numbers, and the total count. Disable the Previous button on page 0 and the Next button on the last page.
1import { useState, useEffect } from 'react'23function PaginatedList() {4 const [page, setPage] = useState(0)5 const [data, setData] = useState<any[]>([])6 const [totalPages, setTotalPages] = useState(0)78 useEffect(() => {9 fetchPage(page).then(({ data, count }) => {10 if (data) setData(data)11 if (count !== null) setTotalPages(Math.ceil(count / PAGE_SIZE))12 })13 }, [page])1415 return (16 <div>17 {data.map((item) => (18 <div key={item.id}>{item.name}</div>19 ))}20 <div>21 <button22 onClick={() => setPage((p) => p - 1)}23 disabled={page === 0}24 >25 Previous26 </button>27 <span>Page {page + 1} of {totalPages}</span>28 <button29 onClick={() => setPage((p) => p + 1)}30 disabled={page >= totalPages - 1}31 >32 Next33 </button>34 </div>35 </div>36 )37}Expected result: A working paginated list with Previous/Next navigation and page count indicator.
Complete working example
1import { useState, useEffect, useCallback } from 'react'2import { createClient } from '@supabase/supabase-js'34const supabase = createClient(5 process.env.NEXT_PUBLIC_SUPABASE_URL!,6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!7)89interface UsePaginatedQueryOptions {10 table: string11 pageSize?: number12 orderBy?: string13 ascending?: boolean14 filters?: Record<string, string>15}1617export function usePaginatedQuery({18 table,19 pageSize = 10,20 orderBy = 'created_at',21 ascending = false,22 filters = {},23}: UsePaginatedQueryOptions) {24 const [data, setData] = useState<any[]>([])25 const [page, setPage] = useState(0)26 const [totalPages, setTotalPages] = useState(0)27 const [totalRows, setTotalRows] = useState(0)28 const [loading, setLoading] = useState(false)2930 const fetchData = useCallback(async () => {31 setLoading(true)32 const from = page * pageSize33 const to = from + pageSize - 13435 let query = supabase36 .from(table)37 .select('*', { count: 'exact' })38 .order(orderBy, { ascending })39 .range(from, to)4041 for (const [key, value] of Object.entries(filters)) {42 query = query.eq(key, value)43 }4445 const { data: rows, count, error } = await query46 if (!error && rows) {47 setData(rows)48 const total = count ?? 049 setTotalRows(total)50 setTotalPages(Math.ceil(total / pageSize))51 }52 setLoading(false)53 }, [table, page, pageSize, orderBy, ascending, filters])5455 useEffect(() => { fetchData() }, [fetchData])5657 const nextPage = () => setPage((p) => Math.min(p + 1, totalPages - 1))58 const prevPage = () => setPage((p) => Math.max(p - 1, 0))59 const goToPage = (p: number) => setPage(Math.max(0, Math.min(p, totalPages - 1)))6061 return {62 data, page, totalPages, totalRows, loading,63 nextPage, prevPage, goToPage,64 hasNext: page < totalPages - 1,65 hasPrev: page > 0,66 }67}Common mistakes when paginating Results in Supabase
Why it's a problem: Using offset pagination on tables with millions of rows, causing deep page queries to take seconds
How to avoid: Switch to keyset (cursor) pagination for large tables. Use .lt() or .gt() on an indexed column instead of .range() to maintain constant query time at any depth.
Why it's a problem: Forgetting to add .order() before .range(), leading to inconsistent page results
How to avoid: Always specify an explicit sort order with .order() before pagination. Without it, PostgreSQL returns rows in an undefined order that can change between queries.
Why it's a problem: Not passing { count: 'exact' } to .select(), making it impossible to show total pages
How to avoid: Add { count: 'exact' } as the second argument to .select() when you need total row count for pagination UI. Note this adds slight overhead, so use { count: 'planned' } or skip it for infinite scroll.
Best practices
- Always combine .range() with .order() to ensure consistent page ordering across requests
- Use { count: 'exact' } for accurate pagination UI, or { count: 'planned' } for faster approximate counts on large tables
- Switch to keyset pagination when offset pagination becomes slow (typically above 10,000 rows)
- Add database indexes on columns used for sorting and cursor values to keep pagination fast
- Disable navigation buttons at page boundaries — Previous on page 0 and Next on the last page
- Consider infinite scroll with keyset pagination for mobile interfaces where page numbers are less important
- Reset to page 0 when filters change to avoid showing an empty page beyond the new result set
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Supabase products table with 50,000 rows. Show me how to implement paginated queries with the JavaScript client, including both offset-based pagination with .range() and cursor-based pagination for better performance. Include a React component with page navigation.
Create a reusable React hook for paginated Supabase queries that supports configurable page size, sort column, sort direction, and filters. Include total page count, loading state, and next/previous page functions.
Frequently asked questions
What is the difference between offset and keyset pagination?
Offset pagination uses LIMIT and OFFSET to skip rows, which gets slower as the offset grows because the database must scan all skipped rows. Keyset pagination uses a WHERE clause on an indexed column to jump directly to the next page, maintaining constant speed at any depth.
Does Supabase have a default limit on query results?
Yes. Supabase limits API responses to 1,000 rows by default. You can change this in the project settings, but it is better to implement proper pagination than to increase the limit.
How do I implement infinite scroll with Supabase?
Use keyset pagination with a Load More button or intersection observer. Fetch the first page on mount, then append subsequent pages to state using the cursor from the last row. This avoids re-fetching previous pages.
Does the count option affect query performance?
The { count: 'exact' } option adds a COUNT(*) query which can be slow on very large tables (millions of rows). Use { count: 'planned' } for a faster but approximate count based on PostgreSQL's table statistics, or omit count entirely for infinite scroll interfaces.
Can I paginate across related tables with joins?
Yes. Use select('*, categories(name)') to include related data and then apply .range() or cursor pagination as normal. The pagination applies to the primary table rows, and each row includes its related data.
Can RapidDev help implement pagination in my Supabase application?
Yes. RapidDev can implement efficient pagination patterns tailored to your data size and access patterns, including keyset pagination, infinite scroll, and optimized queries with proper indexing.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation