ยทUpdated

Supabase Pagination in React: The Complete Guide

Learn to implement pagination with Supabase in React and Next.js. Covers offset and cursor pagination, count optimization, URL state, and TanStack Table integration.

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:

  1. Calculate the start index: (page - 1) * pageSize
  2. Calculate the end index: page * pageSize - 1
  3. Use .range(start, end) with an explicit .order() clause
  4. Handle the response and error
  5. 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 pageCount alongside 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:

ModeSpeedAccuracyUse Case
exactSlow100% accurateSmall tables, critical accuracy
estimatedFastUsually accurateLarge tables, typical pagination
plannedFastestRough estimateVery 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: true tells TanStack Table that you're handling pagination yourself
  • pageCount comes from your server response
  • onPaginationChange syncs table state with URL

Offset vs Cursor Pagination

AspectOffset PaginationCursor Pagination
ImplementationSimple (LIMIT/OFFSET)Complex (keyset-based)
PerformanceDegrades on deep pagesConsistent
Random accessYes (jump to page 50)No (sequential only)
Real-time dataMay skip/duplicate rowsStable results
Best forAdmin tables, finite listsFeeds, 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 timestamp
const { 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 ordering
const { data } = await supabase.from('posts').select('*').range(0, 9);
// Correct: explicit order
const { 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?
Supabase pagination is the process of fetching data in chunks using the .range(from, to) method, which maps to PostgreSQL's LIMIT and OFFSET clauses. This lets you load manageable amounts of data instead of fetching entire tables.
How do I get the total count for pagination?
Add { count: 'estimated' } or { count: 'exact' } to your .select() call. Use 'estimated' for large tables (faster) and 'exact' when accuracy is critical. To get only the count without data, add { head: true }.
Is Supabase .range() zero-indexed?
Yes, both indices are zero-based and inclusive. .range(0, 9) returns the first 10 rows. Calculate start as (page - 1) * pageSize and end as page * pageSize - 1.
Should I use offset or cursor pagination?
Use offset pagination for admin tables and finite lists where users need random page access. Use cursor pagination for infinite scroll, real-time feeds, or tables with millions of rows where users navigate deep.
Why does my pagination show duplicate or missing items?
This happens when you don't specify an .order() clause. Without explicit ordering, PostgreSQL returns rows in an undefined order. Always add .order() before .range().
How do I implement pagination in Next.js App Router?
Store page state in URL searchParams. In Server Components, read searchParams and fetch the appropriate page. In Client Components, use router.push() to update the URL when users change pages.

Next Steps

Some other posts you might like...