Paginate the Project List

So far, our projects.list query uses .collect() to return all projects at once. That works fine when there are a handful of projects. But what if there are hundreds? Loading them all at once would be slow and waste bandwidth.

Pagination solves this by loading data in chunks: show the first few projects, then load more when the user asks for them.

How Pagination Works in Convex

Convex uses cursor-based pagination. When you ask for the first page of results, the server returns those results along with a cursor (an opaque string that marks where this batch ended). To get the next page, you send that cursor back, and the server resumes from that point.

This means the client does not need to track page numbers.

Write the Paginated Query

Update convex/projects.ts. First, add the import for paginationOptsValidator:

  import { query, mutation, internalMutation } from "./_generated/server";
  import { v } from "convex/values";
+ import { paginationOptsValidator } from "convex/server";

Then replace the list query:

export const list = query({
  args: {
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("projects")
      .withIndex("by_deletedAt", (q) => q.eq("deletedAt", undefined))
      .order("desc")
      .paginate(args.paginationOpts);
  },
});

What changed from the original list query:

  • paginationOptsValidator (imported from convex/server) defines the pagination arguments (cursor, number of items). You do not need to define these yourself. Convex provides them.
  • .order("desc") — sorts by _creationTime descending, so the newest projects appear first.
  • .paginate(args.paginationOpts) replaces .collect(). Instead of returning all documents, it returns a page of results along with metadata for fetching the next page.

The return value of .paginate() is an object with:

  • page — an array of documents (this page)
  • isDone — whether all results have been loaded
  • continueCursor — the cursor to fetch the next page

You do not interact with these directly from the client. The usePaginatedQuery hook handles all of it.

Update the Project List Page

Update src/routes/index.tsx to use usePaginatedQuery instead of useQuery:

import { createFileRoute } from "@tanstack/react-router";
import { usePaginatedQuery } from "convex/react";
import { api } from "../../convex/_generated/api";
import ProjectCard from "@/components/project-card";
import CreateProjectDialog from "@/components/create-project-dialog";
import { Button } from "@/components/ui/button";

export const Route = createFileRoute("/")({
  component: ProjectListPage,
});

function ProjectListPage() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.projects.list,
    {},
    { initialNumItems: 6 },
  );

  return (
    <div>
      <div className="mb-6 flex items-center justify-between">
        <h2 className="text-xl font-semibold">Projects</h2>
        <CreateProjectDialog />
      </div>
      {status === "LoadingFirstPage" ? (
        <p className="text-muted-foreground">Loading projects...</p>
      ) : results.length === 0 ? (
        <p className="py-12 text-center text-muted-foreground">
          No projects yet. Create one to get started!
        </p>
      ) : (
        <>
          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
            {results.map((project) => (
              <ProjectCard key={project._id} project={project} />
            ))}
          </div>
          {status === "CanLoadMore" && (
            <div className="mt-6 flex justify-center">
              <Button variant="outline" onClick={() => loadMore(6)}>
                Load More
              </Button>
            </div>
          )}
          {status === "LoadingMore" && (
            <p className="mt-6 text-center text-sm text-muted-foreground">
              Loading more...
            </p>
          )}
        </>
      )}
    </div>
  );
}

How usePaginatedQuery Works

const { results, status, loadMore } = usePaginatedQuery(
  api.projects.list,
  {},
  { initialNumItems: 6 },
);

usePaginatedQuery is similar to useQuery, but designed for paginated data:

  • results — an array of all documents loaded so far. It grows as the user loads more pages. On first render it has up to 6 items. After “Load More” is clicked, it has up to 12, then 18, and so on.
  • status — tells you where you are in the pagination flow:
    • "LoadingFirstPage" — the initial load has not completed yet
    • "CanLoadMore" — there are more results available
    • "LoadingMore" — currently fetching the next page
    • "Exhausted" — all results have been loaded
  • loadMore(n) — a function that loads the next n items

The { initialNumItems: 6 } option determines how many documents to load on the first page. We use 6 because our grid has 3 columns, so 6 fills exactly two rows.

The second argument {} is for query arguments besides pagination options. Our query does not have any extra arguments, so it is an empty object.

Reactive Pagination

Like all Convex queries, paginated queries are reactive. If someone creates a new project while you are viewing the list, it appears in your results without a refresh. If you have loaded two pages (12 items) and a new project is created, the results array automatically grows to 13. The pagination state stays consistent even as the underlying data changes.

Checkpoint: Commit your progress.

git add .
git commit -m "planner-14: Paginate the project list with usePaginatedQuery"
git push