Last updated:
.paginate(options) is the terminal that turns any ctx.db.query(table) chain
into a keyset-paginated read. Unlike .collect() (every row) or .take(n)
(the first n), it returns one page plus an opaque cursor you pass back to
fetch the next. Cursors are keyset, not offset, so paging stays stable as rows
are inserted or deleted ahead of the cursor.
import { query, v } from "@/lunora/_generated/server";
export const list = query.input({ channelId: v.id("channels"), paginationOpts: v.any() }).query(async ({ ctx, args: { channelId, paginationOpts } }) => {
return ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.paginate(paginationOpts);
});.paginate() composes with .withIndex(), .filter(), and .order(): it pages
within the active index in the chosen direction.
PaginationOptions
The argument to .paginate():
| Field | Type | Meaning |
|---|---|---|
numItems | number | Maximum rows to return for this page. |
cursor | string | null | Cursor from the prior page's continueCursor; null/omitted starts at the first page. |
PaginationResult
What .paginate() resolves to:
| Field | Type | Meaning |
|---|---|---|
page | T[] | The rows for this page. |
isDone | boolean | true when this is the last page. |
continueCursor | string | null | Cursor to pass back as the next cursor; null once isDone. |
Paging through a query
Drive it from the first page (cursor: null) and feed continueCursor back in
until isDone:
let cursor: string | null = null;
do {
const { page, isDone, continueCursor } = await ctx.runQuery(api.messages.list, {
channelId,
paginationOpts: { numItems: 50, cursor },
});
for (const message of page) {
// … handle each row
}
cursor = continueCursor;
if (isDone) break;
} while (cursor !== null);Load more / infinite lists on the client
On the client a paginated list is the same query re-run with the next
cursor. The framework adapter hooks (useQuery in @lunora/react,
@lunora/vue, @lunora/solid, @lunora/svelte) wrap this for you: you hold the
accumulated page arrays, track the latest continueCursor, and call "load
more" to re-run the query with cursor set to it. When isDone is true, hide
the button.
// Framework-neutral sketch of the "load more" loop the adapter hooks implement.
const pages: Message[] = [];
let cursor: string | null = null;
let isDone = false;
async function loadMore() {
if (isDone) return;
const result = await client.query(api.messages.list, {
channelId,
paginationOpts: { numItems: 50, cursor },
});
pages.push(...result.page);
cursor = result.continueCursor;
isDone = result.isDone;
}Because queries are reactive, each loaded page stays live. A mutation that touches a row already on screen re-runs the query and pushes the delta to the client without re-fetching the whole list.
Total count ("42 of 1,203")
A page knows how to fetch the next page, not how many rows exist — paginate()
never counts the tail, which is what keeps it O(page) instead of O(table). Ask
for the total with a separate query and subscribe to it like any other:
// lunora/messages.ts
export const total = query.input({ channelId: v.id("channels") }).query(async (ctx, { channelId }) => ctx.db.messages.count({ channelId }));const { results, status, loadMore } = usePaginatedQuery(api.messages.list, { channelId }, { initialNumItems: 50 });
const total = useQuery(api.messages.total, { channelId });
return (
<p>
Showing {results.length} of {total ?? "…"}
</p>
);It is a live query, so an insert or delete updates the count the same tick it
updates the list — no invalidation to wire up. Pass the count query the same
filter args as the list query, minus paginationOpts; a count over a different
where is the classic way to print a total that disagrees with the rows on
screen.
count() scans the index, so on a large table prefer counting a filtered subset
(an index-backed where) over the whole table on every keystroke.