🎉 Native Google & Apple sign-in is here → read the guide
Pagination

Pagination

Most lists are too long to load at once — a table of orders, a bucket of files, your whole user base. Pagination means fetching them in small chunks (“pages”) and asking for the next chunk only when you actually need it (when the user scrolls near the bottom). This page shows two ways to do that:

  1. A built-in Paginator — works everywhere, no extra dependencies. Reach for this first.
  2. An androidx Paging 3 recipe — for Compose apps that already use Paging 3 and want LazyColumn integration, prefetch and placeholders.

Both are built on the same idea: a function that, given an offset and a limit, returns one page of rows.

Plain reads vs. results

Every read in the library returns a SupabaseResult by default, so you handle success and failure explicitly. For pagination that wrapper just gets in the way — a page fetcher wants to return the rows and let errors travel separately. So the paginated endpoints each have a plain variant that returns the list directly and throws on failure:

Default (returns SupabaseResult)Plain (returns the list, throws)
database.selectTyped<T>(…)database.selectTypedOrThrow<T>(…)
storage.list(…)storage.listOrThrow(…)
admin.listUsers(…)admin.listUsersOrThrow(…)

You rarely call these yourself — the Paginator factories below use them under the hood — but they’re there when you want raw data and prefer try/catch.

The built-in Paginator

A Paginator is demand-driven: nothing loads until you call loadNext(), and each call appends one more page. It exposes everything a list screen needs as StateFlows you can observe:

  • items — the rows loaded so far (grows with each page)
  • isLoading — whether a page is in flight
  • endReachedtrue once there are no more rows
  • error — the last failure, or null

Plus loadNext() to fetch the next page and refresh() to start over (for pull-to-refresh).

Database

val pager = database.paginator<Todo>(table = "todos", pageSize = 20) {
    order("created_at", ascending = false)   // always sort by a stable column
}
 
pager.loadNext()        // load the first page
pager.items             // StateFlow<List<Todo>> — observe this in your UI
⚠️

Always order(...) by a stable column. Without a fixed sort, the database can return rows in a different order between requests, so later pages may repeat or skip rows. It’s the one thing you must remember.

Storage

val pager = storage.listPaginator(bucket = "avatars", prefix = "2026/", pageSize = 100)
pager.loadNext()
pager.items   // StateFlow<List<FileObject>>

Admin (users)

GoTrue’s admin list is page-based; the paginator converts offsets for you.

val pager = admin.usersPaginator(perPage = 50)
pager.loadNext()
pager.items   // StateFlow<List<User>>

Driving it from Compose

Observe items as state, and call loadNext() when the last row appears:

@Composable
fun TodoList(pager: Paginator<Todo>) {
    val todos by pager.items.collectAsState()
    val isLoading by pager.isLoading.collectAsState()
 
    LazyColumn {
        itemsIndexed(todos) { index, todo ->
            TodoRow(todo)
            // near the end? ask for more
            if (index >= todos.lastIndex - 3) {
                LaunchedEffect(index) { pager.loadNext() }
            }
        }
        if (isLoading) item { LoadingRow() }
    }
}

That’s the whole feature: build a paginator, observe items, call loadNext().

A complete list screen

A realistic screen also handles refresh, errors and the empty/loading states. Hold the paginator in a ViewModel and expose its flows:

class TodosViewModel(database: DatabaseClient) : ViewModel() {
    val pager = database.paginator<Todo>(table = "todos", pageSize = 20) {
        order("created_at", ascending = false)
    }
 
    init { loadNext() }                                   // first page
 
    fun loadNext() = viewModelScope.launch { pager.loadNext() }
    fun refresh() = viewModelScope.launch { pager.refresh() }   // pull-to-refresh
}

Then bind every state the user can see — rows, the trailing loader, a failed-page row with retry, and the first-load empty state:

@Composable
fun TodosScreen(vm: TodosViewModel) {
    val todos by vm.pager.items.collectAsState()
    val isLoading by vm.pager.isLoading.collectAsState()
    val endReached by vm.pager.endReached.collectAsState()
    val error by vm.pager.error.collectAsState()
 
    LazyColumn {
        itemsIndexed(todos, key = { _, t -> t.id }) { index, todo ->
            TodoRow(todo)
            if (index >= todos.lastIndex - 3 && !endReached) {
                LaunchedEffect(index) { vm.loadNext() }   // prefetch near the end
            }
        }
 
        when {
            error != null -> item { ErrorRow(message = error?.message) { vm.loadNext() } }
            isLoading -> item { LoadingRow() }
            todos.isEmpty() -> item { EmptyState() }       // first load returned nothing
        }
    }
}

error holds the last failure and is cleared on the next successful page, so a retry is just another loadNext(). refresh() resets back to page one — wire it to a PullToRefreshBox or a button.

Without Compose

The paginator is plain Kotlin — nothing Android-specific — so it works the same in a KMP module, a server job or a test. Observe items from any coroutine, or drive it imperatively:

// React to each new page from anywhere.
scope.launch { pager.items.collect { rows -> log("showing ${rows.size}") } }
pager.loadNext()
 
// Or drain the whole list (e.g. an export) — keep going until the end.
while (!pager.endReached.value) {
    pager.loadNext()
    pager.error.value?.let { throw it }   // stop on a failed page
}
val everything = pager.items.value

Using androidx Paging 3

If your Compose app already uses Paging 3, you can plug Supabase into it. The library deliberately does not depend on androidx.paging (it’s Android/Compose opinionated and would force the dependency on every consumer), so the small PagingSource lives in your app — where you already have the dependency. Write it once and reuse it for any table.

Your app adds the dependency: androidx.paging:paging-common (and androidx.paging:paging-compose for the UI). The library stays dependency-free.

// Write once in your app — generic, not tied to any table.
class OffsetPagingSource<T : Any>(
    private val fetch: suspend (offset: Int, limit: Int) -> List<T>,
) : PagingSource<Int, T>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, T> {
        val offset = params.key ?: 0
        return try {
            val rows = fetch(offset, params.loadSize)
            LoadResult.Page(
                data = rows,
                prevKey = if (offset == 0) null else maxOf(0, offset - params.loadSize),
                nextKey = if (rows.size < params.loadSize) null else offset + rows.size,
            )
        } catch (t: Throwable) {
            LoadResult.Error(t)
        }
    }
 
    override fun getRefreshKey(state: PagingState<Int, T>): Int? =
        state.anchorPosition?.let { state.closestPageToPosition(it)?.nextKey?.minus(state.config.pageSize) }
}

Wire Supabase in with a plain read — the only Supabase-specific line:

class TodosViewModel(private val database: DatabaseClient) : ViewModel() {
    val todos = Pager(PagingConfig(pageSize = 20)) {
        OffsetPagingSource { offset, limit ->
            database.selectTypedOrThrow<Todo>("todos") {
                order("created_at", ascending = false)
                limit(limit)
                offset(offset)
            }
        }
    }.flow.cachedIn(viewModelScope)
}
@Composable
fun TodoScreen(viewModel: TodosViewModel) {
    val todos = viewModel.todos.collectAsLazyPagingItems()
    LazyColumn {
        items(todos.itemCount, key = todos.itemKey { it.id }) { i ->
            todos[i]?.let { TodoRow(it) }
        }
    }
}

You get Paging 3’s prefetch, placeholders and LoadState retry; the library stays thin.

Which one should I use?

Built-in Paginatorandroidx Paging 3
Setupnothing — includedapp adds androidx.paging + one PagingSource class
Targetsall 16the ~12 Paging 3 publishes
You getitems/isLoading/endReached/error + loadNext+ auto-prefetch, placeholders, LazyColumn binding
Best formost apps, all platformsCompose apps already on Paging 3

Large or live tables: keyset paging

Offset paging (limit/offset) is simple but slows down deep in big tables, and rows inserted while you scroll can shift the windows. For large or frequently-changing data, prefer keyset (a.k.a. seek) paging: order by an indexed column and ask for rows after the last one you saw. Build a Paginator directly and carry the cursor in the closure, ignoring offset:

var lastCreatedAt: String? = null
 
val pager = Paginator<Todo>(pageSize = 20) { _, limit ->
    database.selectTypedOrThrow<Todo>("todos") {
        order("created_at", ascending = false)
        lastCreatedAt?.let { lt("created_at", it) }   // rows older than the last seen
        limit(limit)
    }.also { page -> lastCreatedAt = page.lastOrNull()?.createdAt ?: lastCreatedAt }
}

This stays fast no matter how deep you scroll, and won’t duplicate or skip rows when the table changes underneath you.