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:
- A built-in
Paginator— works everywhere, no extra dependencies. Reach for this first. - An androidx Paging 3 recipe — for Compose apps that already use Paging 3
and want
LazyColumnintegration, 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 flightendReached—trueonce there are no more rowserror— the last failure, ornull
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 UIAlways 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.valueUsing 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 Paginator | androidx Paging 3 | |
|---|---|---|
| Setup | nothing — included | app adds androidx.paging + one PagingSource class |
| Targets | all 16 | the ~12 Paging 3 publishes |
| You get | items/isLoading/endReached/error + loadNext | + auto-prefetch, placeholders, LazyColumn binding |
| Best for | most apps, all platforms | Compose 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.