Pagination
The admin panel’s list views use a cursor-based Load More pattern, not page-number
pagination. usePaginatedQuery accumulates pages fetched from Convex’s native cursor
pagination on the server, while a smaller client page size slices a visible window out of
what’s already been fetched — so clicking Load More against data that’s already in memory
never round-trips to the server.
How it works
Section titled “How it works”- The server fetches in batches of
paginationOpts.numItems(a Convex cursor page) viafind({ collection, paginationOpts }). - The hook accumulates every document fetched from the server so far, then slices that
buffer into the visible page at
clientPageSizeboundaries. Clicking Load More first exhausts the already-fetched buffer client-side; only once the buffer runs out does it advance the server cursor and issue a new fetch. totalDocsis requested viapaginationOpts.totalDocs: trueand is computed only on the FIRST page. It resolves to anumberon success,nullwhen the collection exceeds Convex’s ~32k-document transaction scan limit, andundefinedbefore it’s loaded or when not requested.initialData(typically a server-fetched first page) seeds the first render with no loading flash — this is what the Next.js admin page passes into the list view before hydration.
Setup: per-collection page size
Section titled “Setup: per-collection page size”admin.view on defineCollection controls both the server/client page-size defaults
usePaginatedQuery reads and the default sort and client page size itself:
import { defineCollection, text } from "@vexcms/core";
export const posts = defineCollection({ slug: "posts", fields: { title: text({ required: true }) }, admin: { view: { serverPageSize: 100, // rows fetched from Convex per server round-trip pageSizeOptions: [10, 25, 50, 100], bulkActions: { delete: true }, sort: { field: "_creationTime", order: "desc" }, pageSize: 25, // client-visible rows revealed per Load More click }, },});serverPageSize should be >= admin.view.pageSize. The built-in admin list view computes the
server fetch size as Math.max(serverPageSize, admin.view.pageSize), so a single Load More
click never needs an extra server round-trip just to fill one client page.
Basic usage: a custom list outside the admin panel
Section titled “Basic usage: a custom list outside the admin panel”Row types are never hand-rolled — vex generate (and vex dev) writes an
interface for every collection and global into your project’s vex.types.ts,
named by the collection’s interfaceName (posts → Post here). Import the
generated type; if the shape drifts from your config, regeneration fixes it and
the compiler tells you.
usePaginatedQuery doesn’t require the admin panel or DataTable — this is a self-contained
“Load more posts” list for a public page:
"use client";
import { usePaginatedQuery } from "@vexcms/react";import type { Post } from "~/vex.types";
export function PostList() { const pagination = usePaginatedQuery<Post>({ query: { collection: "posts", paginationOpts: { numItems: 20, cursor: null }, }, });
return ( <> <ul> {pagination.results.map((post) => ( <li key={post._id}>{post.title}</li> ))} </ul> {!pagination.isDone && ( <button onClick={pagination.loadMore} disabled={pagination.isPending}> {pagination.isPending ? "Loading…" : "Load more"} </button> )} </> );}A table view: usePaginatedQuery + DataTable
Section titled “A table view: usePaginatedQuery + DataTable”You know which collection you’re rendering, so hardcode its slug and use its
generated row type — the same wiring the built-in CollectionListView uses,
specialized to one collection. Page sizes read from the collection’s own
config rather than being repeated inline:
"use client";
import type { ColumnDef } from "@tanstack/react-table";import { DataTable, usePaginatedQuery } from "@vexcms/react";import type { Post } from "~/vex.types";import { posts } from "~/vexcms/collections/posts";
const columns: ColumnDef<Post>[] = [ { accessorKey: "title", header: "Title" }, { accessorKey: "status", header: "Status" },];
const numItems = Math.max( posts.admin.view.state.serverPageSize, posts.admin.view.state.pageSize,);
export function PostsTable() { const pagination = usePaginatedQuery<Post>({ query: { collection: "posts", paginationOpts: { numItems, totalDocs: true, cursor: null }, }, clientPageSize: posts.admin.view.state.pageSize, });
return ( <DataTable data={pagination.results} columns={columns} isDone={pagination.isDone} onLoadMore={pagination.loadMore} isLoadingMore={pagination.isPending} totalCount={pagination.totalDocs} /> );}Showing the total count
Section titled “Showing the total count”find/search cap the total-count scan at admin.view.countLimit (default 10000,
collection-wide, overridable per view) instead of counting the whole matching set — a count
above Convex’s ~32k-document transaction scan limit used to fail outright. totalDocs is
always a number (never null in practice); a new totalDocsCapped flag is true once the
cap is hit, meaning the real count is totalDocs or more, not exact:
<p> {pagination.isPending ? "Loading…" : `${pagination.totalDocs?.toLocaleString() ?? 0} document${pagination.totalDocsCapped ? "+" : ""}`}</p>Lower countLimit on a collection with a cheap, approximate count is fine — it only caps the
displayed total, never the actual paginated rows returned. Lower it for collections with large
documents: the count reads up to countLimit + 1 full documents in one query, and Convex caps a
query at 8 MiB read (10,001 documents averaging ~800 bytes reach that). search always clamps the
cap to 1023, since a Convex search query returns at most 1024 results.
The admin panel’s own table doesn’t use
usePaginatedQuerydirectly — it’s built onuseVexListQuery(@vexcms/react), which adds server/local search switching, sort, and filterwherecomposition on top of the same pagination primitive. Reach forusePaginatedQuerydirectly only when building a custom list outside the admin panel;useVexListQueryis usually the better starting point once you need more than a raw list.
Seeding the first render from the server
Section titled “Seeding the first render from the server”Pass a server-fetched first page as initialData so the client hook renders immediately
instead of showing a loading state on mount — this mirrors what the Next.js admin route
does before handing off to the client component:
// app/posts/page.tsx (server component)import { fetchQuery } from "convex/nextjs";import { vexConvexApi } from "@vexcms/core";
const initialData = await fetchQuery(vexConvexApi.findPaginated, { collection: "posts", paginationOpts: { numItems: 20, totalDocs: true, cursor: null },});Pass initialData straight into usePaginatedQuery({ initialData, query, clientPageSize })
on the client — it’s only consulted on the very first render (cursor === null).
Gotchas
Section titled “Gotchas”loadMoretakes no arguments. Page size is fixed for the hook’s lifetime viaclientPageSize/paginationOpts.numItems, not passed per call.isPendingcovers both “first load” and “Load More in flight.” There’s no separate loading flag — a first-render skeleton and a Load More spinner read the same boolean.totalDocs/totalDocsCappedre-read on every first-page response, not just the first mount. Changingwhere/sort, switching views, or a live insert landing while the hook is mounted all update the displayed total — there’s no stale latch to work around.DataTable’stotalCountprop is the hook’stotalDocsfield, renamed. The component prop and the hook’s return field don’t share a name — double-check which one you’re threading through when composing a custom list view.