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.
Pagination is the default
Section titled “Pagination is the default”A #[crud] list — REST or GraphQL — is keyset-paginated out of the box:
#[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 = noneopts out into the full (ability-scoped) collection in one response. Even then the backstop holds:CrudService::listnever returns more thanLIST_CAP(1 000) rows, and logs awarnwhen it truncates. Reservenonefor 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:
#[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.
Keyset, the REST shape
Section titled “Keyset, the REST shape”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.
curl -s '/items?first=20'# [ { "id": "...", "name": "..." }, ... ]# x-next-cursor: 0193f1b2-...
curl -s '/items?first=20&after=0193f1b2-...'# next pageThe 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.
Keyset, the GraphQL shape
Section titled “Keyset, the GraphQL shape”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.
Offset, the hand-written envelope
Section titled “Offset, the hand-written envelope”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.
What Page<M> carries
Section titled “What Page<M> carries”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).
A custom paginated query
Section titled “A custom paginated query”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).
Picking between the two
Section titled “Picking between the two”| Property | Keyset (default) | Offset (hand-written) |
|---|---|---|
| Stable under concurrent inserts | yes | no |
| O(1) on the PK index | yes | no (large offset = scan) |
| Returns a total count | no | yes |
| Jump-to-page | no | yes |
| REST shape | [T] + x-next-cursor | not 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.
Going further
Section titled “Going further”- Database — the entity that the
paginateflag lives on. - CRUD — the
paginateknob on#[crud(...)]. - Repo and executor —
Repo::scopedandRepo::connbehind the cursor query. - GraphQL —
PageArgs, thePage<T>envelope on resolvers.