Beyond CRUD
Pagination
Lesson 1 of 3
What you'll learn
- See why
.collect()doesn't scale - Paginate a query with
.paginate()and a cursor - Understand how the client requests the next page
.collect() reads every matching row into memory. That's fine for a handful of records and a problem for thousands — slow queries, hit limits, janky UI. For any list that can grow, paginate.
A Convex query becomes paginated by taking a paginationOpts argument and calling .paginate() instead of .collect():
import { paginationOptsValidator } from "convex/server";
export const listPosts = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, { paginationOpts }) => {
return ctx.db
.query("posts")
.withIndex("by_created")
.order("desc")
.paginate(paginationOpts);
},
});
It returns { page, isDone, continueCursor }. The page is this slice of rows; continueCursor is an opaque pointer to where the next slice begins.
On the client
The usePaginatedQuery hook manages cursors for you — you just call loadMore:
const { results, status, loadMore } = usePaginatedQuery(
api.posts.listPosts,
{},
{ initialNumItems: 20 }
);
Always paginate over an index
Paginate a query that walks an index (and usually .order()), so each page is a cheap, ordered range scan rather than a re-sort of the whole table.
The challenge implements cursor paging over an array — the same idea Convex applies to a table. Run it to fetch two pages.
Each call returns a page plus the cursor for the next one. Run it and follow the cursor.
Next: doing work that reaches outside the database, and scheduling work for later.
Sign in to save your progress across devices.