Skip to content
DocsconceptsDocumentation

Pagination

Keyset pagination with .paginate(options) — page through a query and build an infinite list on the client.

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():

FieldTypeMeaning
numItemsnumberMaximum rows to return for this page.
cursorstring | nullCursor from the prior page's continueCursor; null/omitted starts at the first page.

PaginationResult

What .paginate() resolves to:

FieldTypeMeaning
pageT[]The rows for this page.
isDonebooleantrue when this is the last page.
continueCursorstring | nullCursor 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.