engineering

Building paginator-rs: A Composable Pagination Library for Rust

Every project I’ve built that exposes a list endpoint ends up with the same file somewhere. A PaginationParams struct with page, per_page, sort_by, sort_direction. A PaginatedResponse<T> with a data vec and a meta block. The offset math. Then the filters — status, date range, category. Then search. Then the client asks for cursor pagination because offset breaks on large datasets. Three projects in, I extracted it into a library.

paginator-rs is a Rust pagination library with a fluent builder API, 14 filter operators, cursor pagination, and database integrations. It’s on crates.io at v0.2.2. This post covers why the API is shaped the way it is, how the filter system works, and what the SeaORM integration actually does.

The problem

The boilerplate isn’t complicated — it’s just tedious and it creeps. You start with page and per_page. Then a sort field and direction. Then a few filters. Then the client wants full-text search across three columns. Then a power user hits page 800 and you need cursor pagination because OFFSET 16000 on a million-row table is slow and skips rows on concurrent writes.

By the time you’ve done this properly in one project, you’ve written:

  • A params struct that maps from query strings
  • Offset/limit math with clamping
  • A SQL WHERE clause builder that’s either string-concatenated (unsafe) or half-baked
  • A response type with total, total_pages, has_next, has_prev
  • Cursor encoding/decoding

That’s the core of paginator-rs. Pull it in once, stop rewriting it.

The builder API — fluent sub-builders

The main entry point is Paginator::new(). Basic usage looks like this:

use paginator_rs::Paginator;

let params = Paginator::new()
    .page(1)
    .per_page(20)
    .sort().desc("created_at")
    .filter()
        .eq("status", FilterValue::String("active".into()))
        .gt("age", FilterValue::Int(18))
        .apply()
    .search()
        .query("developer")
        .fields(["title", "bio"])
        .apply()
    .build();

The design I settled on is “fluent sub-builders” — methods like .filter() and .search() return a new sub-builder that owns the parent. When you call .apply() on a sub-builder, it writes its state back into the parent’s PaginationParams and returns the parent. Sort is the exception: since you’re setting exactly one field and direction, .sort().desc("created_at") returns the parent directly without needing .apply().

The type that makes this work is HasParams:

pub trait HasParams {
    fn params_mut(&mut self) -> &mut PaginationParams;
}

Every sub-builder is generic over its parent: FilterBuilder<P>. The .apply() method has a P: HasParams bound:

pub fn apply(self) -> P
where
    P: HasParams,
{
    let mut parent = self.parent.unwrap();
    parent.params_mut().filters.extend(self.filters);
    parent
}

This gives you compile-time enforcement: .apply() is only available when the builder was created via a fluent chain (where P is Paginator or some other HasParams implementor), not when you create one standalone with FilterBuilder::new(). The standalone builders produce FilterBuilder<()>, and () doesn’t implement HasParams, so .apply() doesn’t exist on them. They get .build() instead, which returns the value directly.

I also kept a PaginatorBuilder (flat API) alongside the new Paginator sub-builder API for backward compatibility. The flat version has methods like .filter_eq(field, value) and .sort_desc(field) directly on the root builder. Both implement IntoPaginationParams, so they plug into the same downstream integration code.

Filter system — 14 operators

The filter system is built around two enums:

pub enum FilterOperator {
    Eq, Ne, Gt, Lt, Gte, Lte,
    Like, ILike,
    In, NotIn,
    IsNull, IsNotNull,
    Between,
    Contains,
}

pub enum FilterValue {
    String(String),
    Int(i64),
    Float(f64),
    Bool(bool),
    Array(Vec<FilterValue>),
    Null,
}

Each Filter has a field name, operator, and value. They generate parameterized SQL via to_sql_where() and SurrealQL via to_surrealql_where(). No string concatenation — values go through to_sql_string() which handles quoting and escaping.

SurrealQL has different syntax for some operators: Like and ILike both map to ~, In maps to INSIDE, NotIn maps to NOT INSIDE, and Between expands to field >= min AND field <= max (no native BETWEEN keyword). That kind of per-database divergence is exactly why it’s worth having an abstraction here.

For when you want filters without the full paginator chain, FilterBuilder works standalone:

use paginator_rs::FilterBuilder;

let filters = FilterBuilder::new()
    .eq("role", FilterValue::String("admin".into()))
    .ilike("email", "%@example.com%")
    .between(
        "created_at",
        FilterValue::String("2025-01-01".into()),
        FilterValue::String("2025-12-31".into()),
    )
    .build(); // returns Vec<Filter>

Cursor pagination

Offset pagination has a well-known problem with large datasets: OFFSET 10000 forces the database to scan and discard 10,000 rows on every query. And if rows are being inserted concurrently, pages shift under you — page 3 at request time might not be page 3 by the time you load page 4.

Cursor pagination solves both. Instead of “give me items 200–220”, you say “give me 20 items after the item with id abc123”. The query becomes a range scan on an indexed column.

use paginator_rs::{CursorBuilder, CursorValue};

let cursor = CursorBuilder::new()
    .after("id", CursorValue::Uuid("550e8400-e29b-41d4-a716-446655440000".into()))
    .build();

Or in the fluent chain:

let params = Paginator::new()
    .per_page(20)
    .cursor()
        .after("created_at", CursorValue::Int(1743340800))
        .apply()
    .build();

The Cursor struct holds a field, a typed value (String, Int, Float, or Uuid), and a direction (After or Before). When you need to send the cursor to a client, .encode() serializes it to JSON and base64-encodes it. The client gets an opaque string and sends it back on the next request. .from_encoded() on the builder handles the decode:

let params = Paginator::new()
    .per_page(20)
    .cursor()
        .from_encoded(&cursor_token)?
        .apply()
    .build();

CursorValue::Uuid is stored as a string but gets parsed to a real UUID when handed to SeaORM, so it maps to the correct SQL type rather than falling back to a string comparison.

The response type

PaginatorResponse<T> pairs a data vec with a meta block:

pub struct PaginatorResponse<T> {
    pub data: Vec<T>,
    pub meta: PaginatorResponseMeta,
}

pub struct PaginatorResponseMeta {
    pub page: u32,
    pub per_page: u32,
    pub total: Option<u32>,        // None when disable_total_count
    pub total_pages: Option<u32>,  // None when disable_total_count
    pub has_next: bool,
    pub has_prev: bool,
    pub next_cursor: Option<String>,
    pub prev_cursor: Option<String>,
}

There are three constructors for different scenarios:

  • PaginatorResponseMeta::new(page, per_page, total) — standard offset pagination, runs a COUNT query
  • PaginatorResponseMeta::new_without_total(page, per_page, has_next) — skips the COUNT; has_next is determined by fetching per_page + 1 items and checking if you got the extra one
  • PaginatorResponseMeta::new_with_cursors(page, per_page, total, has_next, next_cursor, prev_cursor) — cursor mode

total and total_pages are Option and tagged with #[serde(skip_serializing_if = "Option::is_none")], so they don’t appear in the JSON when absent. The client can handle both cases.

SeaORM integration

paginator-sea-orm provides a PaginateSeaOrm async trait that extends SeaORM’s Select:

#[async_trait]
pub trait PaginateSeaOrm<'db, C>
where
    C: ConnectionTrait,
{
    type Item;

    async fn paginate_with(
        self,
        db: &'db C,
        params: &PaginationParams,
    ) -> Result<PaginatorResponse<Self::Item>, PaginatorError>;
}

Usage on any Select<E>:

let result = User::find()
    .paginate_with(&db, &params)
    .await?;

The implementation builds a SeaORM Condition from the params — filters, cursor range, and search — applies it, runs the count query (unless disable_total_count is set), then runs the data query with offset/limit or cursor limit. The response type is assembled based on whether cursor mode is active.

One detail worth knowing: ILike is not available on all databases via SeaORM’s API, so the integration handles it as LOWER(field) LIKE lower_pattern:

(FilterOperator::ILike, FilterValue::String(pattern)) => {
    Expr::expr(Expr::cust(format!("LOWER({})", filter.field)))
        .like(pattern.to_lowercase())
}

Same semantics, works on Postgres, MySQL, and SQLite. Native ILIKE would fail on MySQL and SQLite.

For cases where the sort field name from the client doesn’t map 1:1 to a column (say, the client sends "name" but you want to sort on last_name, first_name), there’s paginate_with_sort:

let result = paginate_with_sort(
    User::find(),
    &db,
    &params,
    |query, field, direction| match field {
        "name" => query
            .order_by(user::Column::LastName, direction.into())
            .order_by(user::Column::FirstName, direction.into()),
        _ => query,
    },
)
.await?;

The sort function receives the raw Select, the sort field string, and the direction. You return a modified Select. The rest of the pagination (filters, count, offset) runs as normal.

Real-world usage — aysiem

In practice, a lot of endpoints don’t need the full filter builder. In aysiem, most list endpoints accept a basic PaginationQuery from query params and call a shared paginated_response() helper:

pub fn paginated_response<T: Serialize>(
    data: Vec<T>,
    total: u64,
    params: &PaginationQuery,
) -> PaginatorResponse<T> {
    let per_page = params.limit();
    let page = params.page.max(1);
    let total_u32 = total as u32;
    let total_pages = total_u32.div_ceil(per_page);

    PaginatorResponse {
        data,
        meta: PaginatorResponseMeta {
            page,
            per_page,
            total: Some(total_u32),
            total_pages: Some(total_pages),
            has_next: page < total_pages,
            has_prev: page > 1,
            next_cursor: None,
            prev_cursor: None,
        },
    }
}

This wraps the response type without touching the full builder. The PaginatorResponse<T> serializes consistently across all endpoints regardless of whether you used the builder or constructed it manually, so the client always gets the same shape.

Endpoints that need filtering or search use the Paginator builder directly and pass the resulting PaginationParams to paginate_with.

Crate structure

The library is three crates:

  • paginator-rs — core builder API (Paginator, sub-builders), re-exports from paginator-utils
  • paginator-utils — shared types: PaginationParams, Filter, Cursor, SearchParams, PaginatorResponse
  • paginator-sea-orm — SeaORM integration, the PaginateSeaOrm trait

paginator-utils is separate so other database integration crates can depend on the shared types without pulling in the full builder. If I add an sqlx integration crate, it imports paginator-utils, not paginator-rs. paginator-sea-orm has feature flags for the underlying SQLx runtime: sqlx-postgres, sqlx-mysql, sqlx-sqlite, runtime-tokio, runtime-async-std.

What I learned

The sub-builder pattern is ergonomic to use but verbose to implement. Each sub-builder needs to be generic over its parent, implement the right bounds, and the HasParams trait needs to propagate correctly through the chain. Writing it is more work than a flat builder, but the call site is noticeably cleaner when you’re composing multiple orthogonal concerns (sort + filter + search + cursor) at once.

Keeping the flat PaginatorBuilder around was the right call. The sub-builder API is strictly more ergonomic, but refactoring every call site in two codebases is not free. Both implement IntoPaginationParams, so existing code keeps working. I can migrate call sites incrementally.

Each database has its own quirks. ILIKE exists in Postgres but not MySQL or SQLite. SurrealDB uses ~ for LIKE and INSIDE for IN. BETWEEN in SurrealQL is just range comparisons. None of this is complicated — it’s just the kind of thing you only discover when you actually try to run the query, and having a single place to handle it is worthwhile.

SQL generation without the ORM is useful too. The to_sql_where() method on Filter exists for contexts where you’re writing raw SQL — migrations, reporting queries, admin tools — and want to apply the same filter types without a full ORM integration. It’s a simple method but it comes up.

The next things on the roadmap are web framework extractors for Axum, Rocket, and Actix-web — so pagination params can be extracted directly from request query strings without the manual mapping layer. The docs site mentions them, but they’re not published yet.

If you’re building list APIs in Rust and want a consistent pagination layer without the boilerplate: crates.io/crates/paginator-rs, docs at paginator-rs.msdqn.dev.