Suprnova ships three paginators that match Laravel's surface line-for-line:
length-aware (knows the total), simple (one query per page), and cursor
(opaque keyset). All three derive Serialize into the Laravel-shaped
JSON Inertia and JSON:API consumers already understand - you fetch a
page and return it; nothing else is required.
use crateUser;
let page = query
.filter
.order_by_desc
.paginate
.await?;
That one call runs the COUNT(*) and the LIMIT/OFFSET page fetch,
parses ?page=N from the active request, and returns a
LengthAwarePaginator<User> ready to ship. The two siblings -
simple_paginate(20) and cursor_paginate(20) - return the same shape
of value with different trade-offs. The rest of this chapter is which
one to reach for, what each one costs, and how the JSON arrives.
Picking a paginator
The fastest way to choose is the trade-off table:
| Method | Type | Queries / page | Knows total? | Use when |
|---|---|---|---|---|
paginate(n) |
LengthAwarePaginator<M> |
2 (COUNT(*) + page) |
yes | UI shows numeric pages or "page 3 of 17" |
simple_paginate(n) |
Paginator<M> |
1 (LIMIT n+1) |
no | Large tables; a "Next" button is enough |
cursor_paginate(n) |
CursorPaginator<M> |
1 (LIMIT n+1) |
no | Infinite scroll; deep pages on hot tables |
The cost difference matters once your table is large. COUNT(*) over
a hundred million rows is the most expensive query in your request
budget. simple_paginate saves the count. cursor_paginate saves the
count and avoids the OFFSET N linear scan that bites every
deep-page request on a big table - a cursor seek is O(1)-ish with the
right index, regardless of where in the result set the user is.
Why Suprnova diverges
Laravel's paginators carry URL-building helpers - nextPageUrl(),
previousPageUrl(), the links array of {url, label, page, active}
descriptors that Blade renders. Suprnova's raw Serialize impl emits
the data slice plus the counters; URL construction lives on the
response-shape constructors that already own URL context:
Inertia::paginate attaches Inertia
scroll metadata (page identifiers, not absolute URLs);
Resource::paginated attaches JSON:API
links.{self,first,last,prev,next} per the JSON:API recommendation.
Two reasons for the split. First, the URL the client should see depends
on which protocol surface is rendering it - Inertia keys off page
identifiers, JSON:API wants absolute hrefs. Second, the paginator
doesn't know the request's base URL by default; the helpers that do
know it can attach the URLs once, where they belong. If you do need
URLs on the bare paginator (custom JSON envelope, telemetry payload,
test assertion), call with_path(...) and use url_for_page(n) -
covered in the URL generation section.
paginate - length-aware
use LengthAwarePaginator;
use crateUser;
pub async
The struct's public fields:
The JSON the derived Serialize emits:
path is omitted from the JSON when unset; from and to are null
when the page is empty (no rows on this page, or the requested page is
past the last page).
Reading ?page=N automatically
paginate(n) reads the current page from ?page=N on the active
request via Context::query_param. Missing, empty, non-numeric, and
zero values clamp to 1. There's nothing to wire up - if a request is
in scope, the parameter is read.
Multiple paginators on one page
When a page renders more than one paginated list, give each its own
query-string key with paginate_using:
let posts = query
.order_by_desc
.paginate_using
.await?;
let comments = query
.order_by_desc
.paginate_using
.await?;
paginate_using also sets page_name on the returned paginator so
url_for_page builds URLs with the same key:
posts.url_for_page; // "/posts?posts_page=2" (when path is set)
comments.url_for_page; // "/posts?comments_page=3"
Page-position predicates
The full Laravel AbstractPaginator predicate set is implemented:
page.has_more_pages; // current_page < last_page
page.on_first_page; // current_page <= 1
page.on_last_page; // !has_more_pages()
page.has_pages; // we're not on page 1 OR more pages exist
page.is_empty; // data.is_empty()
page.is_not_empty; // !is_empty()
page.count; // data.len() - page slice, not total
count() is the slice size, not the total - Laravel's Countable
shape; for the total use the total field directly.
simple_paginate - one query, no count
use Paginator;
use crateUser;
let page: = query
.order_by_desc
.simple_paginate
.await?;
JSON:
The trick is in the SQL. simple_paginate(20) issues LIMIT 21, looks
at whether the 21st row came back, sets has_more from that, and
truncates data back to 20. One query per page; no COUNT(*).
You give up total, last_page, from, and to. In exchange you can
paginate tables where COUNT(*) is too expensive to run on every page
load. The UI surface is "Next" / "Previous" buttons, not "page 7 of
142".
The same predicate set as the length-aware paginator is implemented:
has_more_pages(), on_first_page(), on_last_page(), has_pages(),
is_empty(), is_not_empty(), count().
cursor_paginate - opaque keyset
use CursorPaginator;
use crateUser;
let page: = query
.cursor_paginate
.await?;
JSON:
next_cursor and prev_cursor are always present as JSON keys (null
when absent) so client schemas can rely on field presence; path is
omitted when unset.
How cursors work on the wire
The client passes the previous page's cursor through ?cursor=<opaque>:
GET /api/users?cursor=eyJ0IjoiQmlnSW50IiwidiI6MTAwLCJkIjoibmV4dCJ9...
cursor_paginate decodes the cursor, walks the keyset filter
(pk > boundary ASC for next; pk < boundary DESC for prev,
reversed back to ASC), fetches LIMIT n+1 rows, and re-emits
next_cursor / prev_cursor as the page's neighbours exist. It's
bidirectional - the client can walk forward and back without losing
its position.
Cursor pagination replaces any existing ORDER BY on the builder.
A stable total order over the primary key is required for the keyset
filter to slice the table deterministically; an arbitrary ORDER BY random_score() cursor would skip and duplicate rows. If you need a
non-PK sort, switch to paginate / simple_paginate.
Cursors are encrypted and authenticated
Suprnova cursors are not Laravel's base64-JSON plaintext. The wire
cursor is the keyset boundary (a typed sea_orm::Value - Int,
BigInt, Uuid, datetimes, decimals, strings, bytes) plus a direction
tag, JSON-encoded and then sealed with AES-256-GCM via the framework
Crypt keyring (bound to CryptPurpose::Cursor, so a cursor
ciphertext can never be replayed into any other surface - cookie, 2FA
secret, cast).
This means three things in practice:
- No tampering. A client that flips bits in
?cursor=gets a 400Invalid pagination cursor, not a different page of data. - No information leak. The boundary value (often a primary key, sometimes a timestamp) is sealed inside the cursor - clients can't enumerate ranges by editing it.
- Typed boundaries round-trip losslessly. The wire envelope tags
the SeaORM variant (
"BigInt","Uuid", etc.), so on decode the value re-binds with the same SQL type the original column emitted. No string-coercion bugs across Postgres / MySQL / SQLite.
There is no plaintext fallback. If Crypt is not initialised - which
should be impossible after Server::from_config - encoding errors
rather than emitting a forgeable cursor.
Why Suprnova diverges
Laravel's cursor paginator is forward-only by default and the wire
cursor is a base64-encoded JSON blob - readable, editable, replayable.
Suprnova's cursor is bidirectional (matching the cursorPaginate()
surface Laravel added later) and is authenticated end-to-end so the
client can't construct or alter one. The Rust ecosystem already has
AES-GCM as a primitive; using it costs the framework one extra trait
impl and gives every cursor a security property a plaintext base64
payload can't offer.
The facade - Pagination::length_aware / Pagination::cursor
Most chapters of this manual show pagination through the Eloquent
builder, because that's the common path. If you're building a SeaORM
Select<E> directly - say, joining onto a non-model query for a
report - the Pagination facade is the equivalent surface:
use ;
use EntityTrait;
let select = find // or any SeaORM Select<E>
.filter;
let page: =
length_aware.await?;
The facade also offers length_aware_on(conn, ...) and
cursor_on(conn, ...) for routing to a specific named connection, and
a typed cursor(query, cursor, per_page, order_col) form that takes
the keyset column explicitly - used when the cursor sorts on something
other than the primary key.
Routing rules match the Eloquent builder. An ambient
DB::transaction is honoured (both the COUNT and the page query run on
the transaction's connection), and a registered __read_replica__
connection is used automatically for reads. The __primary__ sentinel
selects the default pool when you want to bypass the replica.
Validation - per_page == 0
All three methods reject per_page == 0:
let result = query.paginate.await;
assert!;
The error renders as HTTP 400 with the standard error body. There is no
silent "empty page" - a zero page size is always wrong and is rejected
at the call site, matching the Eloquent builder and the Pagination
facade. The same validation lives on cursor_paginate, simple_paginate,
Pagination::length_aware, Pagination::length_aware_on,
Pagination::cursor, and Pagination::cursor_on - one rule, six
entry points.
The current_page value is clamped, not validated: 0 becomes 1,
negative numbers from a defensive frontend cannot happen (the parser is
u64), and any ?page=N greater than last_page returns a paginator
with empty data plus from/to of None. Walking past the end is
the client's mistake, not an error.
Error shape
| Condition | Variant | HTTP |
|---|---|---|
per_page == 0 |
FrameworkError::ParamError { param_name: "per_page" } |
400 |
| Tampered / invalid cursor | FrameworkError::Domain ("Invalid pagination cursor") |
400 |
Crypt not initialised at cursor decode |
FrameworkError::Internal |
500 |
Cursor variant mismatch on decode_cursor |
FrameworkError::Internal |
500 |
| Underlying DB failure | FrameworkError::Database |
500 |
The tampered-cursor case is the one to remember. Cursors are read
directly off the wire - the ?cursor=… query string is attacker input
by definition, and bit-flipped base64 and replayed ciphertext are
expected failure modes, not server bugs. The decryption step downgrades
to a 400 Invalid pagination cursor so client-triggerable failures
don't pollute the 500 telemetry channel. The static message gives the
client nothing to probe with.
Post-decrypt failures (JSON parse, variant-tag dispatch, direction parse) stay 500 - any byte sequence that survived AEAD authentication was produced by us, so a malformed payload past that point is a framework bug worth flagging.
URL generation and paths
The raw paginator carries an optional path field. When set,
url_for_page(n) and the cursor link emission use it to build query
strings:
let page = query
.paginate
.await?
.with_path;
page.url_for_page; // "/api/users?page=1"
page.url_for_page; // "/api/users?page=2"
When the base path already carries a query string, the separator
switches to & so the URL stays well-formed:
let page = query
.paginate
.await?
.with_path;
page.url_for_page; // "/users?sort=name&page=2"
If path is unset, url_for_page falls back to a bare relative
query: ?page=2. The page-parameter name comes from
with_page_name(...) (defaulting to "page"); paginate_using(name, n)
sets it automatically so the generated URLs use the same key the
paginator was driven from. The parameter name is form-urlencoded, so
even a name with reserved characters can't corrupt the URL.
Cursor paginators have the same shape: with_path(...) sets the base,
with_cursor_name(...) overrides the query key (defaults to "cursor"),
and the JSON:API link builder picks them up automatically.
Most apps don't call url_for_page directly - they hand the paginator
to one of the two integration surfaces below, which build the URLs the
right way for their protocol.
Inertia integration - infinite scroll props
For Inertia front-ends, the Inertia::paginate(component, key, paginator)
helper attaches the paginator as a scroll prop:
use Inertia;
pub async
All three paginators work here - LengthAwarePaginator, Paginator, and
CursorPaginator. The metadata page-name comes from the paginator
itself: "page" for the two offset paginators, "cursor" for
CursorPaginator. The client receives the rows under the chosen prop key
plus a ScrollMetadata descriptor with current_page, next_page,
previous_page (page identifiers for the offset paginators; cursor
strings for cursor paginators) - which the useInfiniteScroll /
WhenVisible Inertia helpers consume for infinite scroll.
simple_paginate is worth calling out, because a listing over a table
big enough to make COUNT(*) the dominant cost of the request is exactly
where an Inertia collection page hurts:
let users = query
.order_by_asc
.simple_paginate // no COUNT, one query
.await?;
Ok
Its next_page comes from the LIMIT n+1 overflow probe rather than
from a computed last page, since there is no total to compute one from.
The client gets "there is another page" instead of "there are 4,812
pages" - which is all an infinite-scroll UI ever reads.
Projecting rows before they go out
Paginators have no map / through (Laravel's do). Rebuild from the
public fields instead - the counters and cursors describe the query, so
they carry across a change of row type unchanged:
let page = query.cursor_paginate.await?;
let page = new;
Worth doing rather than serialising the model directly whenever the route is unauthenticated and the model carries anything the caller should not see. A cursor over a user table hands out one page at a time, but it hands out every page eventually.
The same helper exists as a chainable method on
InertiaResponse::paginate(key, paginator) if you want to mix a
paginator with other props:
inertia_response!
.with
.paginate
.into
See Inertia Responses for the broader prop model.
JSON:API integration - Resource::paginated
For JSON:API consumers, Resource::paginated(paginator) builds the
full envelope:
use Resource;
pub async
The response carries:
data- every row rendered through the model'sIntoJsonResource.meta.pagination-{ total, per_page, current_page, last_page }for length-aware;{ next_cursor, prev_cursor }for cursor.links.{self,first,last,prev,next}- absolute hrefs for the length-aware paginator (built frompath);links.{prev,next}for the cursor paginator.
Both paginator types implement the Paginated<T> trait that
Resource::paginated consumes - there is no separate code path for
length-aware vs cursor. If you build a custom paginator-like type that
implements Paginated<T>, it composes the same way.
See JSON:API resources for the resource model.
Custom JSON envelopes
If neither Inertia nor JSON:API matches your client, ship the
paginator directly through json_response!:
let page = query.paginate.await?;
Ok
Or just hand the whole paginator across - the derived Serialize impl
emits the shape documented above:
Ok
The fields are public; reshape as your contract requires.
Routing across connections
Pagination respects the same multi-connection routing the Eloquent
builder uses. Inside a DB::transaction(...) the COUNT and the page
query both run on the transaction's connection - they never split
across connections, so the count never disagrees with the page it
described. A registered __read_replica__ is used automatically for
reads outside a transaction. To pin a paginator to a specific named
connection use the _on(connection, ...) variants on the Pagination
facade, or Builder::on("replica_b").paginate(20) from the Eloquent
side.
See Eloquent - multi-connection routing for the routing contract.
When to reach for which
A rough decision tree:
- Numeric page UI is part of the design →
paginate. You needlast_pageto render "Page 3 of 17", and the COUNT cost is OK on your table size. - "Next" / "Previous" buttons only, large table →
simple_paginate. One query per page; you give uptotalandlast_pagebut the page load halves. - Infinite scroll →
cursor_paginate. Bidirectional cursors mean the client can keep scrolling past page 1000 without the OFFSET scanning thousands of rows first. - Tail of a hot append-only feed →
cursor_paginate. Keyset ordering by primary key is concurrent-safe: new rows land beyond the cursor, never inside it. OFFSET-based pagination skips rows under inserts. - Building a
Select<E>outside an Eloquent model →Pagination::length_aware/Pagination::cursor. Same trade-offs; the facade is the model-less equivalent.
When in doubt, start with paginate. Move to simple_paginate when
the COUNT(*) shows up in your slow query log. Move to
cursor_paginate when deep pages start dominating request time, or
when the UI is infinite scroll.
Where each piece lives
| Piece | File |
|---|---|
Pagination facade, Paginated<T> trait |
framework/src/pagination/mod.rs |
LengthAwarePaginator<T> |
framework/src/pagination/length_aware.rs |
Paginator<T> (simple) |
framework/src/pagination/simple.rs |
CursorPaginator<T>, CursorDirection, encode_value, decode_value |
framework/src/pagination/cursor.rs |
IntoInertiaScroll bridge |
framework/src/pagination/inertia.rs |
Builder::paginate / simple_paginate / cursor_paginate |
framework/src/eloquent/builder.rs |
Inertia::paginate, InertiaResponse::paginate |
framework/src/inertia/facade.rs, framework/src/inertia/response.rs |
Resource::paginated, JsonApi::paginated |
framework/src/resources/response.rs |
Next
- Eloquent API - the model layer that drives every
paginator returned from
Builder::paginate* - Query Builder - the model-less queries that compose
with
Pagination::length_awareandPagination::cursor - Inertia Responses - how scroll props attach paginators to Inertia pages
- JSON:API resources -
Resource::paginated, links, meta, and thePaginated<T>trait - Error Model - the
FrameworkError::paramvalidation rule and the cursor-tampering downgrade
