Skip to content

Pagination

Two paginators ship in the box. Keyset (cursor over the primary key) is the default — O(1) on the index, stable under concurrent inserts, paired naturally with UUID-v7 keys. Every #[crud]-generated list is keyset-paginated unless you opt out. Offset (page + per_page) is the classic shape admin tables expect; its wire types ship in the box and you wire them in a hand-written operation.

SeaORM’s Cursor API drives the keyset half; the offset side is plain LIMIT + OFFSET.

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

src/items/http/controller.rs
#[crud(
service = svc,
entity = ItemEntity,
output = Item,
create = CreateItemDto,
update = UpdateItemDto,
)]
impl ItemsController {}

No paginate knob needed — paginate = cursor is the default. The two other values:

  • paginate = none 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.
  • paginate = page (offset) is not wired yet on either surface — the macro says so at compile time. The offset wire types below are for hand-written operations.

For the offset envelope, one more flag lives on the entity:

src/items/entity.rs
#[expose(name = "Item", complex, paginate)]

On the entity, paginate emits the ItemPage envelope and the shared PageArgs input type — the wire types a hand-written offset operation serializes to.

The generated GET /items returns a plain Vec<Item> 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 '/items?first=20'
# [ { "id": "...", "name": "..." }, ... ]
# x-next-cursor: 0193f1b2-...
curl -s '/items?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 {
items(first: 20) {
id
name
}
}
# next page: items(first: 20, after: "<last id of the previous page>")

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

For an admin table that genuinely needs page numbers and a total, write the operation yourself against the ItemPage envelope and PageArgs input that #[expose(paginate)] emits:

query {
itemsPage(args: { page: 1, perPage: 20 }) {
items { id name }
total
page
perPage
totalPages
hasNextPage
hasPreviousPage
}
}

PageArgs validates page >= 1 and 1 <= per_page <= 100 at the boundary; ItemPage::new(items, total, &args) derives the page-count and has-more flags so the math lives in one place.

The keyset shape, before serialization:

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

The keyset 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 items above a threshold — call Repo::scoped and the same cursor helpers:

impl ItemsService {
pub async fn page_in_stock(
&self,
first: u64,
after: Option<Uuid>,
) -> Result<Page<Item>, ItemError> {
let conn = Repo::<Items>::conn()?;
let limit = nest_rs_seaorm::page::clamp_page_size(first);
let mut cursor = Repo::<Items>::scoped(Action::Read)
.filter(entity::Column::Quantity.gt(0))
.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(Item::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).

PropertyKeyset (default)Offset (hand-written)
Stable under concurrent insertsyesno
O(1) on the PK indexyesno (large offset = scan)
Returns a total countnoyes
Jump-to-pagenoyes
REST shape[T] + x-next-cursornot wired today
GraphQL shape[T] + first/after args<T>Page envelope + counts

Keep the keyset default. Reach for offset when the consumer genuinely needs page numbers and a total — typically an admin UI table — and hand-write that operation.

  • Database — the entity that the paginate flag lives on.
  • CRUD — the paginate knob on #[crud(...)].
  • Repo and executorRepo::scoped and Repo::conn behind the cursor query.
  • GraphQLPageArgs, the Page<T> envelope on resolvers.