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 fromconvex/server) defines the pagination arguments (cursor, number of items). You do not need to define these yourself. Convex provides them..order("desc")— sorts by_creationTimedescending, so the newest projects appear first..paginate(args.paginationOpts)replaces.collect(). Instead of returning all documents, it returns apageof 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 loadedcontinueCursor— 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 nextnitems
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