Skip to content

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.

  • The server fetches in batches of paginationOpts.numItems (a Convex cursor page) via find({ collection, paginationOpts }).
  • The hook accumulates every document fetched from the server so far, then slices that buffer into the visible page at clientPageSize boundaries. 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.
  • totalDocs is requested via paginationOpts.totalDocs: true and is computed only on the FIRST page. It resolves to a number on success, null when the collection exceeds Convex’s ~32k-document transaction scan limit, and undefined before 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.

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}
/>
);
}

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 usePaginatedQuery directly — it’s built on useVexListQuery (@vexcms/react), which adds server/local search switching, sort, and filter where composition on top of the same pagination primitive. Reach for usePaginatedQuery directly only when building a custom list outside the admin panel; useVexListQuery is usually the better starting point once you need more than a raw list.

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).

  • loadMore takes no arguments. Page size is fixed for the hook’s lifetime via clientPageSize/paginationOpts.numItems, not passed per call.
  • isPending covers 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/totalDocsCapped re-read on every first-page response, not just the first mount. Changing where/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’s totalCount prop is the hook’s totalDocs field, 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.