Skip to content

Pagination

One paginator ships in the box: keyset, a cursor over the primary key. It is O(1) on the index, stable under concurrent inserts, and pairs naturally with UUID-v7 keys. Every #[crud]-generated list is keyset-paginated unless you opt out.

SeaORM’s Cursor API drives it.

A #[crud] list — REST or GraphQL — is keyset-paginated out of the box:

src/posts/http/controller.rs
#[crud(
service = svc,
entity = PostEntity,
output = Post,
create = CreatePost,
update = UpdatePost,
)]
impl PostsController {}

No paginate knob needed — paginate = cursor is the default. The one other value is paginate = none, which opts out into the full (ability-scoped) collection in one response. Even then the backstop holds: CrudService::list never returns more than LIST_CAP (1 000) rows, and logs a warn when it truncates. Reserve none for small, finite collections.

The generated GET /posts returns a plain Vec<Post> body plus an x-next-cursor response header when more rows remain. The body stays a flat array so response masking works unchanged.

Terminal window
curl -s '/posts?first=20'
# [ { "id": "...", "title": "..." }, ... ]
# x-next-cursor: 0193f1b2-...
curl -s '/posts?first=20&after=0193f1b2-...'
# next page

The query carries first (page size, defaulting to 20, clamped to 1..=100) and after (the cursor returned by the previous response). An unparseable cursor pages from the start — never an error.

The generated list query takes the same two cursor arguments and returns a plain list — the body stays maskable, exactly like REST:

query {
posts(first: 20) {
id
title
}
}
# next page: posts(first: 20, after: "<last id of the previous page>")

UUID-v7 keys are time-ordered, so the cursor is just the previous page’s last id — no opaque cursor type. An empty page means you reached the end.

The keyset shape, before serialization:

pub struct Page<M> {
pub items: Vec<M>,
pub next_cursor: Option<Uuid>,
pub has_more: bool,
}

The implementation fetches limit + 1 rows, truncates the probe row from items, and uses its presence to set has_more and next_cursor. The cursor is the last visible row’s primary key — present only when there is more to fetch.

CrudService::page(first, after) returns a Page<E::Model> directly; callers paginating a custom query reach for Repo::<E>::page(first, after).

When the auto-emitted page does not match — say you want only one author’s posts — call Repo::scoped and the same cursor helpers:

impl PostsService {
pub async fn page_by_author(
&self,
author_id: Uuid,
first: u64,
after: Option<Uuid>,
) -> Result<Page<Post>, ServiceError> {
let conn = Repo::<Posts>::conn()?;
let limit = nest_rs_seaorm::page::clamp_page_size(first);
let mut cursor = Repo::<Posts>::scoped(Action::Read)
.filter(entity::Column::AuthorId.eq(author_id))
.cursor_by(entity::Column::Id);
if let Some(after) = after {
cursor.after(after);
}
cursor.first(limit + 1);
let rows = cursor.all(&conn).await?;
let (rows, has_more) = nest_rs_seaorm::page::split_overfetched(rows, limit);
let next_cursor = has_more.then(|| rows.last()?.id);
Ok(Page {
items: rows.iter().map(Post::from).collect(),
next_cursor,
has_more,
})
}
}

Repo::scoped(Action::Read) is the entry point — the ability filter applies, and a request without one denies every row (fail-closed).

Keyset buys stability under concurrent inserts and O(1) reads on the PK index by giving up two things: no total count, and no jump-to-page.

There is no offset paginator in the framework, and no paginate = page mode — one pagination story, one wire shape per surface. A consumer that genuinely needs page numbers and a total (typically an admin table) writes that operation by hand: a Repo::scoped(Action::Read) query with .offset()/.limit() plus a .count(), returning a shape you own — accepting that OFFSET n makes the database walk and discard n rows, so past a few thousand it is a scan.

  • Database — the entity behind the generated list.
  • CRUD — the paginate knob on #[crud(...)].
  • Repo and executorRepo::scoped and Repo::conn behind the cursor query.
  • GraphQL — the list query on a resolver.

Built by YV17labs