Supabase pagination uses the .range(from, to) method to fetch a slice of rows from your PostgreSQL database. Combined with .select('*', { count: 'estimated' }), you can build efficient paginated tables in React and Next.js Server Components without fetching all rows at once. If you've ever frozen a browser tab loading 10,000 rows, you know why this matters.
This guide covers:
- Offset pagination with
.range() - Getting row counts efficiently
- URL-based pagination in Next.js App Router
- Integrating with TanStack Table
- When to use cursor-based pagination instead
How Supabase Pagination Works
Supabase's .range(from, to) method maps directly to PostgreSQL's LIMIT and OFFSET clauses. Both indices are zero-based and inclusive, so .range(0, 9) returns the first 10 rows. Yes, the inclusive upper bound is confusing. No, I don't know why they designed it this way.
const { data, error } = await supabase .from('posts') .select('*') .order('created_at', { ascending: false }) .range(0, 9);How to paginate with Supabase:
- Calculate the start index:
(page - 1) * pageSize - Calculate the end index:
page * pageSize - 1 - Use
.range(start, end)with an explicit.order()clause - Handle the response and error
- Update pagination controls based on total count
The .order() clause is required for consistent pagination. Without explicit ordering, PostgreSQL returns rows in an undefined order, and users may see duplicate or missing items across pages.
Building a Pagination Service
A dedicated service layer keeps pagination logic reusable across your application. Here's a production-ready pattern:
import { SupabaseClient } from '@supabase/supabase-js';import { Database } from '@/types/database';type Client = SupabaseClient<Database>;export function createPaginationService(client: Client) { return { async getPosts(params: { page: number; pageSize?: number; sortBy?: 'created_at' | 'title'; query?: string; }) { const pageSize = params.pageSize ?? 20; const startOffset = (params.page - 1) * pageSize; const endOffset = params.page * pageSize - 1; let query = client .from('posts') .select('id, title, created_at, author_id', { count: 'estimated', }) .range(startOffset, endOffset) .order(params.sortBy ?? 'created_at', { ascending: false }); if (params.query) { query = query.ilike('title', `%${params.query}%`); } const { data, error, count } = await query; if (error) { throw error; } const pageCount = count ? Math.ceil(count / pageSize) : 0; return { data, count, pageCount, pageSize }; }, };}Keep in mind:
- Select only the columns you need. Fetching
*pulls all columns, which wastes bandwidth and can expose sensitive data. - Use
count: 'estimated'for large tables. This uses PostgreSQL's statistics instead of counting every row. - Return
pageCountalongside the data so UI components can render pagination controls. - Consider adding
.limit(pageSize)after.range()as a safety belt. If your range calculation is off by one, the limit ensures you never fetch more rows than intended.
Getting Row Counts
Supabase offers three count modes:
| Mode | Speed | Accuracy | Use Case |
|---|---|---|---|
exact | Slow | 100% accurate | Small tables, critical accuracy |
estimated | Fast | Usually accurate | Large tables, typical pagination |
planned | Fastest | Rough estimate | Very large tables, "many results" UI |
For most pagination, estimated hits the right balance. If you need exact counts on large tables, consider caching the count and refreshing it periodically rather than querying on every page load.
Gotcha: Getting only the count without fetching rows requires head: true:
const { count, error } = await supabase .from('posts') .select('*', { count: 'exact', head: true });This executes a COUNT(*) query without returning any row data.
URL-Based Pagination in Next.js App Router
Modern Next.js applications should store pagination state in the URL. This makes pages shareable, bookmarkable, and properly handles browser back/forward navigation.
Server Component Pattern
In Next.js App Router, Server Components receive searchParams as a prop:
interface PageProps { searchParams: Promise<{ page?: string; sort_by?: 'created_at' | 'title'; query?: string; }>;}export default async function PostsPage({ searchParams }: PageProps) { const params = await searchParams; const page = Number(params.page ?? 1); const sortBy = params.sort_by ?? 'created_at'; const client = await createServerClient(); const service = createPaginationService(client); const { data: posts, pageCount, pageSize } = await service.getPosts({ page, sortBy, query: params.query, }); return ( <PostsTable posts={posts} page={page} pageCount={pageCount} pageSize={pageSize} /> );}Client-Side Navigation
For pagination controls, update the URL without a full page reload:
'use client';import { useRouter } from 'next/navigation';import { useCallback } from 'react';export function usePagination() { const router = useRouter(); const navigateToPage = useCallback( (pageIndex: number) => { const url = new URL(window.location.href); url.searchParams.set('page', String(pageIndex + 1)); router.push(url.pathname + url.search); }, [router] ); return { navigateToPage };}This pattern keeps the server as the source of truth while providing smooth client-side transitions.
Integrating with TanStack Table
TanStack Table (React Table v8) is the standard for building data tables in React. Here's how to wire it up with server-side pagination:
'use client';import { useReactTable, getCoreRowModel, ColumnDef, PaginationState,} from '@tanstack/react-table';import { useState } from 'react';interface PostsTableProps { posts: Post[]; page: number; pageCount: number; pageSize: number;}export function PostsTable({ posts, page, pageCount, pageSize,}: PostsTableProps) { const { navigateToPage } = usePagination(); const [pagination, setPagination] = useState<PaginationState>({ pageIndex: page - 1, pageSize, }); const columns: ColumnDef<Post>[] = [ { accessorKey: 'title', header: 'Title', }, { accessorKey: 'created_at', header: 'Created', cell: ({ getValue }) => new Date(getValue<string>()).toLocaleDateString(), }, ]; const table = useReactTable({ data: posts, columns, getCoreRowModel: getCoreRowModel(), manualPagination: true, pageCount, state: { pagination }, onPaginationChange: (updater) => { const next = typeof updater === 'function' ? updater(pagination) : updater; setPagination(next); navigateToPage(next.pageIndex); }, }); return ( <> {/* Table rendering... */} <div className="flex gap-2"> <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} > Previous </button> <span> Page {pagination.pageIndex + 1} of {pageCount} </span> <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} > Next </button> </div> </> );}Key settings:
manualPagination: truetells TanStack Table that you're handling pagination yourselfpageCountcomes from your server responseonPaginationChangesyncs table state with URL
Offset vs Cursor Pagination
| Aspect | Offset Pagination | Cursor Pagination |
|---|---|---|
| Implementation | Simple (LIMIT/OFFSET) | Complex (keyset-based) |
| Performance | Degrades on deep pages | Consistent |
| Random access | Yes (jump to page 50) | No (sequential only) |
| Real-time data | May skip/duplicate rows | Stable results |
| Best for | Admin tables, finite lists | Feeds, infinite scroll |
When to use cursor pagination:
- Tables with 100k+ rows where users navigate deep
- Real-time data with frequent inserts/deletes
- Infinite scroll UIs where page numbers don't matter
When offset is fine:
- Admin dashboards with reasonable data volumes
- Search results where users rarely go past page 5
- Most CRUD interfaces
Supabase doesn't have built-in cursor pagination, but you can implement it with filters:
// Cursor-based: fetch items after a specific timestampconst { data } = await supabase .from('posts') .select('*') .order('created_at', { ascending: false }) .lt('created_at', cursor) // cursor = last item's created_at .limit(20);This avoids the performance penalty of OFFSET on large tables.
Common Mistakes
1. Forgetting to order results
Without .order(), pagination is unpredictable:
// Wrong: no orderingconst { data } = await supabase.from('posts').select('*').range(0, 9);// Correct: explicit orderconst { data } = await supabase .from('posts') .select('*') .order('created_at', { ascending: false }) .range(0, 9);2. Off-by-one errors in range calculation
The .range() method is inclusive on both ends. For page 1 with 10 items:
- Start:
(1 - 1) * 10 = 0 - End:
1 * 10 - 1 = 9
This returns rows 0-9 (10 items).
3. Using count: 'exact' on large tables
Exact counts scan the entire table. On tables with millions of rows, this can take seconds. Use estimated for pagination UI and reserve exact for exports or reports.
4. Not indexing the sort column
If you're ordering by created_at, ensure it's indexed:
CREATE INDEX idx_posts_created_at ON posts (created_at DESC);Without an index, PostgreSQL sorts the entire table on every request.
Quick Recommendation
Supabase pagination with .range() is best for:
- Admin dashboards and CRUD interfaces
- Tables under 100k rows
- Use cases where users need random page access
Skip offset pagination if:
- You have millions of rows and deep navigation
- Data changes frequently during user sessions
- You're building infinite scroll (use cursor instead)
Our pick: Start with offset pagination using count: 'estimated'. In the dozen or so apps I've built with Supabase, I've only needed cursor pagination once, for a real-time activity feed. Switch to cursor-based only when you hit performance issues or need infinite scroll.
Performance Checklist
- [ ] Index your sort columns (
created_at,updated_at, etc.) - [ ] Use
count: 'estimated'for large tables - [ ] Select only needed columns, not
* - [ ] Limit maximum page size (e.g., cap at 100)
- [ ] Cache counts if they're expensive to compute
- [ ] Consider RLS policies impact on query performance
Frequently Asked Questions
What is Supabase pagination?
How do I get the total count for pagination?
Is Supabase .range() zero-indexed?
Should I use offset or cursor pagination?
Why does my pagination show duplicate or missing items?
How do I implement pagination in Next.js App Router?
Next Steps
- Learn how Supabase RLS policies affect pagination query performance
- Explore React Query integration for client-side caching
- Compare Drizzle vs Prisma for typed database queries