Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Paginate Results in Supabase

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.

What you'll learn

  • How to implement offset pagination with .range()
  • How to implement keyset (cursor) pagination for large datasets
  • How to get total row count for pagination UI
  • How to combine pagination with sorting and filtering
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner9 min read10-15 minSupabase (all plans), @supabase/supabase-js v2+March 2026RapidDev Engineering Team
TL;DR

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

1

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.

typescript
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 process.env.NEXT_PUBLIC_SUPABASE_URL!,
5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
6)
7
8const PAGE_SIZE = 10
9
10async function fetchPage(page: number) {
11 const from = page * PAGE_SIZE
12 const to = from + PAGE_SIZE - 1
13
14 const { data, error, count } = await supabase
15 .from('products')
16 .select('*', { count: 'exact' }) // Returns total count
17 .order('created_at', { ascending: false })
18 .range(from, to)
19
20 return { data, error, count }
21}
22
23// Page 0: rows 0-9
24// Page 1: rows 10-19
25// Page 2: rows 20-29

Expected result: Each call returns exactly PAGE_SIZE rows (or fewer on the last page) plus the total count for building page navigation.

2

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.

typescript
1// Exact count (accurate but slower on very large tables)
2const { data, count } = await supabase
3 .from('products')
4 .select('*', { count: 'exact' })
5 .eq('category', 'electronics')
6 .order('created_at', { ascending: false })
7 .range(0, 9)
8
9const totalPages = Math.ceil((count ?? 0) / PAGE_SIZE)
10console.log(`Page 1 of ${totalPages} (${count} total items)`)
11
12// Head-only count (no data returned, just the count)
13const { count: totalRows } = await supabase
14 .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.

3

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.

typescript
1async function fetchNextPage(cursor?: string) {
2 let query = supabase
3 .from('products')
4 .select('*')
5 .order('created_at', { ascending: false })
6 .limit(PAGE_SIZE)
7
8 if (cursor) {
9 // Fetch rows with created_at before the cursor
10 query = query.lt('created_at', cursor)
11 }
12
13 const { data, error } = await query
14
15 // The next cursor is the last row's created_at
16 const nextCursor = data && data.length === PAGE_SIZE
17 ? data[data.length - 1].created_at
18 : null
19
20 return { data, error, nextCursor }
21}
22
23// Usage:
24// First page: fetchNextPage()
25// Next page: fetchNextPage('2024-03-15T10:30:00Z')
26// No more pages when nextCursor is null

Expected result: Each page loads in constant time regardless of depth. The nextCursor value is null when there are no more pages.

4

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.

typescript
1async function fetchFilteredPage(
2 page: number,
3 category?: string,
4 search?: string
5) {
6 const from = page * PAGE_SIZE
7 const to = from + PAGE_SIZE - 1
8
9 let query = supabase
10 .from('products')
11 .select('*', { count: 'exact' })
12 .order('price', { ascending: true })
13 .range(from, to)
14
15 // Apply optional filters
16 if (category) {
17 query = query.eq('category', category)
18 }
19 if (search) {
20 query = query.ilike('name', `%${search}%`)
21 }
22
23 const { data, error, count } = await query
24 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.

5

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.

typescript
1import { useState, useEffect } from 'react'
2
3function PaginatedList() {
4 const [page, setPage] = useState(0)
5 const [data, setData] = useState<any[]>([])
6 const [totalPages, setTotalPages] = useState(0)
7
8 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])
14
15 return (
16 <div>
17 {data.map((item) => (
18 <div key={item.id}>{item.name}</div>
19 ))}
20 <div>
21 <button
22 onClick={() => setPage((p) => p - 1)}
23 disabled={page === 0}
24 >
25 Previous
26 </button>
27 <span>Page {page + 1} of {totalPages}</span>
28 <button
29 onClick={() => setPage((p) => p + 1)}
30 disabled={page >= totalPages - 1}
31 >
32 Next
33 </button>
34 </div>
35 </div>
36 )
37}

Expected result: A working paginated list with Previous/Next navigation and page count indicator.

Complete working example

use-paginated-query.ts
1import { useState, useEffect, useCallback } from 'react'
2import { createClient } from '@supabase/supabase-js'
3
4const supabase = createClient(
5 process.env.NEXT_PUBLIC_SUPABASE_URL!,
6 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
7)
8
9interface UsePaginatedQueryOptions {
10 table: string
11 pageSize?: number
12 orderBy?: string
13 ascending?: boolean
14 filters?: Record<string, string>
15}
16
17export 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)
29
30 const fetchData = useCallback(async () => {
31 setLoading(true)
32 const from = page * pageSize
33 const to = from + pageSize - 1
34
35 let query = supabase
36 .from(table)
37 .select('*', { count: 'exact' })
38 .order(orderBy, { ascending })
39 .range(from, to)
40
41 for (const [key, value] of Object.entries(filters)) {
42 query = query.eq(key, value)
43 }
44
45 const { data: rows, count, error } = await query
46 if (!error && rows) {
47 setData(rows)
48 const total = count ?? 0
49 setTotalRows(total)
50 setTotalPages(Math.ceil(total / pageSize))
51 }
52 setLoading(false)
53 }, [table, page, pageSize, orderBy, ascending, filters])
54
55 useEffect(() => { fetchData() }, [fetchData])
56
57 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)))
60
61 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.

ChatGPT Prompt

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.

Supabase Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.