BuildBot

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.

Cursor pagination

Each call returns a page plus the cursor for the next one. Run it and follow the cursor.

Loading editor…

Next: doing work that reaches outside the database, and scheduling work for later.

Sign in to save your progress across devices.