Paginator — KMP pagination library for Android, iOS, JVM & Web
Pagination / paging library for Kotlin Multiplatform (Android, iOS, JVM, Desktop, Server, JS,
Wasm).
A pure-Kotlin alternative to Jetpack Paging 3 with cursor pagination, bidirectional scroll,
bookmarks, page caching, element-level CRUD, infinite scroll, prefetch and Flow-based UI state.
Keywords: Kotlin Multiplatform pagination · KMP paging · Android paging library · iOS paging ·
Kotlin JS pagination · Kotlin Wasm pagination · web pagination · cursor pagination · infinite
scroll ·
endless list · load more · Jetpack Paging 3 alternative · bidirectional pagination ·
chat / messenger feed · GraphQL connections · coroutines · Flow.

Paginator is a powerful, flexible pagination library for Kotlin Multiplatform (KMP) —
Android, iOS, JVM, Desktop, JS and Wasm — that goes far beyond simple "load next page" patterns. It
is a
production-ready alternative to Jetpack Paging 3 / Pager / AsyncPagingDataDiffer with a
full-featured page management system: jumping to arbitrary pages, bidirectional navigation,
bookmarks, page caching, cursor-based pagination, element-level CRUD, incomplete page handling,
capacity management and reactive UI state via Kotlin Flows.
The library is split into a shared core plus two pagination strategies that you import
independently, so a consumer only pays for the variant they actually need:
Both build on paginator-core, which holds the shared page-state model, caches, CRUD surface, UI
state and snapshot flow. UI bindings ship as matching pairs:
paginator-compose-offset / paginator-compose-cursor for Compose Multiplatform and
paginator-view-offset / paginator-view-cursor for Android RecyclerView.
Built entirely with pure Kotlin and without platform-specific dependencies,
Paginator can be seamlessly used across all layers of an application
— from data to domain to presentation — while preserving Clean Architecture principles and proper layer separation.
Supported targets: Android · JVM · iosX64 · iosArm64 · iosSimulatorArm64 · js · wasmJs
Table of Contents
Why Paginator? (vs Jetpack Paging 3)
Most Android developers reach for Jetpack Paging 3, which is Android-centric in practice
(KMP targets exist in upstream sources, but the published artifacts and ecosystem — Room,
RecyclerView, Compose adapters — are Android-first), ViewModel/UI-coupled and intentionally
opinionated. Paginator was built for the cases Paging 3 doesn't cover well:
See the side-by-side
write-up: Paging 3 is good. Until you need something more.
Installation
The library is published to Maven Central. No additional repository configuration needed.
Since 10.1.1 the suite is split into per-strategy modules so consumers only pay for the variant
they actually use:
The recommended way is to import the BOM once and then declare only the artifacts you actually
use — the BOM keeps the suite aligned on your classpath:
dependencies {
implementation(platform("io.github.jamal-wia:paginator-bom:10.1.1"))
implementation("io.github.jamal-wia:paginator-offset")
implementation()
implementation()
}
paginator-core is brought in automatically by every other module — never declare it directly.
For Kotlin Multiplatform, Gradle automatically resolves the correct platform artifact
(paginator-offset-jvm, paginator-offset-iosArm64, paginator-offset-js, etc.) from the KMP
metadata. The paginator-compose-* modules go in the shared Compose source set;
paginator-view-* is Android-only and belongs in the Android source set.
The BOM only pins Paginator artifacts; it does not constrain Compose, Kotlin, AndroidX,
or anything else on your classpath.
Supported targets
paginator-core, paginator-offset, paginator-cursor:
Android · JVM · iosX64 · iosArm64 · iosSimulatorArm64 · js · wasmJs.
Migrating from 8.x
The 8.x line shipped a single paginator artifact (+ paginator-compose, paginator-view).
10.1.1 replaces them with the table above and renames Kotlin packages accordingly. Find/replace
in your project:
Rule of thumb: types whose name starts with Cursor* live in com.jamal_aliev.paginator.cursor,
Paginator / MutablePaginator / BookmarkInt / LoadResult and the page-number cache /
eviction / serialization helpers live in , everything else
(bookmarks base, page state, logger, exceptions, prefetch options, reactive cache plumbing) lives
in .
What each UI artifact does
paginator-compose-offset / provide scroll-driven prefetch for
/ / / (and horizontal
counterparts) — no manual / plumbing. The recommended entry point
is + the DSL — zero manual numbers ( is read
from , header / footer counts are tallied by the DSL):
val listState = rememberLazyListState()
val paged = paginator.rememberPaginated(state = listState)
LazyColumn(state = listState) {
paginated(paged) {
header { StickyTitle() }
items(uiState.items, key = { it.id }) { Row(it) }
appendIndicator { AppendIndicator(uiState.appendState) }
}
}
A one-call PrefetchOnScroll(state, dataItemCount, …) and a low-level
rememberPrefetchController + BindToLazyList are also available if you want to keep
counts explicit or hold a reference to the controller. See
docs/7. prefetch.md for the full
guide — including PrefetchOptions, reactive error handling via ,
and advanced knobs (, ).
/ remove the plumbing entirely
and offer three layers of integration: (auto-tracks from
), (one-call factory + bind), and the
low-level for -scoped controllers. All three
support , , and ,
install both and (so partial first pages
don't stall pagination), and clean up on .
binding.recyclerView.adapter = ConcatAdapter(headerAdapter, dataAdapter, appendIndicatorAdapter)
binding.recyclerView.layoutManager = LinearLayoutManager(context)
val paged = paginator.bindPaginated(
recyclerView = binding.recyclerView,
lifecycleOwner = viewLifecycleOwner,
headerCount = { headerAdapter.itemCount },
footerCount = { appendIndicatorAdapter.itemCount },
)
Reactive prefetch errors via PrefetchErrorChannel (StateFlow), runtime knobs through
PrefetchOptions (shared with the Compose binding), and stable PageLoadGuard /
CursorLoadGuard are also available.
See docs/7. prefetch.md for details.
Quick Start
Step 1: Create a Paginator
The simplest way to create a MutablePaginator is via the DSL builder:
import com.jamal_aliev.paginator.offset.dsl.mutablePaginator
import com.jamal_aliev.paginator.offset.load.LoadResult
class FeedViewModel : ViewModel() {
private val paginator = mutablePaginator<Item> {
load { page -> LoadResult(repository.loadPage(page)) }
}
}
The load { } block is the only required call — every other knob (capacity, cache strategy,
logger, bookmarks, custom PageState factories) has sensible defaults. See
DSL Builder for the full configuration surface.
If you only need read-only navigation, use paginator<T> { … } instead — it returns a
Paginator<T>, so element-level mutations are not exposed at the call site.
The load lambda receives an Int page number and should return a LoadResult<T> wrapping
your data list. For the simplest case, just wrap with LoadResult(list). The direct constructor
form (MutablePaginator(load = { … })) is also still available if you prefer it.
Step 2: Observe and Start
Subscribe to paginator.uiState to receive UI updates, then start the paginator by jumping to the
first page:
init {
paginator.uiState
.onEach { state ->
when (state) {
is PaginatorUiState.Content -> showContent(state.items)
is PaginatorUiState.Loading -> showLoading()
is PaginatorUiState.Empty -> showEmpty()
is PaginatorUiState.Error -> showError(state.cause)
is PaginatorUiState.Idle -> Unit
}
}
.flowOn(Dispatchers.Main)
.launchIn(viewModelScope)
viewModelScope.launch {
paginator.jump(bookmark = BookmarkInt(page = 1))
}
}
uiState emits Idle / Loading / Empty / Error / Content(items, prependState, appendState)
so your UI does not have to reason about individual s. If you need raw page-level access,
collect instead. See
.
Step 3: Navigate
fun loadMore() {
viewModelScope.launch { paginator.goNextPage() }
}
fun loadPrevious() {
viewModelScope.launch { paginator.goPreviousPage() }
}
Step 4: Release
When the paginator is no longer needed, release its resources:
override fun onCleared() {
paginator.release()
super.onCleared()
}
Infinite Scroll / Infinite Feed
Paginator works perfectly for a simple infinite scroll — and this is a first-class use case, not an
afterthought.
Every feature in the library is strictly opt-in. If all you need is "load the next page when the
user scrolls down", the entire setup is what you already saw in Quick Start: one load lambda,
one uiState observer, and goNextPage() on scroll. Nothing else is required.
What you still get for free, with zero extra code:
ProgressPage while the next page loads — no manual loading flag needed
ErrorPage with the previously cached data intact — a failed request won't clear the screen
- Incomplete page detection — if the server returns fewer items than expected, the paginator quietly
re-requests on the next scroll instead of silently stopping
Start with the simplest setup. Adopt advanced features only if and when your product actually needs
them.
Cursor-Based Pagination
If your backend returns opaque continuation tokens instead of numeric page offsets (GraphQL
connections, chat feeds, activity streams, Slack/Instagram/Reddit-style APIs), reach for the
cursor variant:
import com.jamal_aliev.paginator.cursor.bookmark.CursorBookmark
import com.jamal_aliev.paginator.cursor.dsl.mutableCursorPaginator
import com.jamal_aliev.paginator.cursor.load.CursorLoadResult
val messages = mutableCursorPaginator<Message>(capacity = 50) {
load { cursor ->
val page = api.getMessages(cursor?.self ? String)
CursorLoadResult(
= page.items,
bookmark = CursorBookmark(
prev = page.prevCursor,
self = page.selfCursor,
next = page.nextCursor,
),
)
}
}
viewModelScope.launch {
messages.restart()
messages.goNextPage()
messages.goPreviousPage()
}
The cursor paginator shares caches, CRUD, UI state (paginator.uiState), snapshot flow,
transaction { }, prefetch controller, logger, and serialization with the offset variant — it
differs only in how pages are addressed. Read the full guide at
Cursor-Based Pagination.
Features
Articles
In-depth articles comparing Paginator with Jetpack Paging 3 and demonstrating real-world
implementation patterns:
English
- Paging 3 is good. Until you need something more. —
side-by-side comparison of Paginator and Paging 3 on a realistic feed
- Messenger on Paginator. Real-world tasks. —
building a production-grade messenger feed: bidirectional scroll, cursor pagination, CRUD,
interweaving
- Why I wrote Paginator instead of Paging 3. —
the author's perspective: which design decisions in Paging 3 hit a ceiling and how Paginator
is built to avoid them
Русский
Documentation
Detailed documentation lives in the docs/ directory:
Maintainer docs:
- Releasing a New Version — publishing the library to Maven Central
License