Pagination
Add stable cursor pagination — or numbered pages — to a plugin API route and navigate its results from a typed plugin page.
VitNode pages with cursors by default. Cursors keep page boundaries stable even while records are created or deleted, and they cost the same whether you are on page 2 or page 20,000.
When a list needs numbered pages — 1, 2, 3 … 38, the kind admin tables show — the same route also accepts ?page=. You do not have to choose up front: one route serves both.
Quick start
Add a paginated API route
Use withPagination from @vitnode/core/api/lib/with-pagination:
import { getColumns } from 'drizzle-orm'
import {
withPagination,
zodPaginationQuery,
} from '@vitnode/core/api/lib/with-pagination'
import { blog_posts } from '@/database/posts'
export const getPostsRoute = buildRoute({
pluginId: 'blog',
route: {
method: 'get',
path: '/',
request: {
query: zodPaginationQuery,
},
},
handler: async (c) => {
const data = await withPagination({
c,
params: { query: c.req.valid('query') },
primaryCursor: blog_posts.id,
table: blog_posts,
orderBy: { column: blog_posts.createdAt, order: 'desc' },
query: async ({ cursorSelection, limit, offset, where, orderBy }) =>
await c
.get('db')
.select({ ...getColumns(blog_posts), ...cursorSelection })
.from(blog_posts)
.where(where)
.orderBy(orderBy)
.limit(limit)
.offset(offset),
})
return c.json(data)
},
})The route automatically accepts ?cursor=...&first=10 (cursor walk) or ?page=3&first=10 (numbered page), and returns { edges, pageInfo }.
Always spread offset into the statement. Without it, ?page= silently
returns the first page over and over.
Cursors or numbered pages?
| Metric | ?page= (offset) | ?cursor= (keyset) |
|---|---|---|
| Insert / Delete mid-walk | Rows shift, duplicate, or skip | Unaffected (stable pointer) |
| Deep pagination (page 10,000) | Walks every skipped row | Single indexed B-tree lookup |
| Sort order stability | Prone to non-deterministic ties | Guaranteed by primaryCursor tiebreaker |
| Jump to an arbitrary page | Yes | No — you can only step |
| "Page 4 of 38" | Yes | No — there is no page number |
Rule of thumb: numbered pages for admin tables, where people want page 7 and the lists are short; cursors for public feeds and infinite scroll, where depth is unbounded and nobody counts pages.
Index the column you order by
Both modes need an index on the ordered tuple, and neither is fast without one:
index('blog_posts_created_at_id_idx').on(t.createdAt, t.id)The cursor predicate is emitted as a row comparison — (createdAt, id) < ($1, $2) — precisely so Postgres can seek straight to the boundary on that index. Measured on a million rows, the seek reaches the hundred-thousandth row in 0.19ms. Without the index, the same query scans and sorts the whole table on every page.
withPagination Options
Prop
Type
Integer, bigint and UUID identifiers
primaryCursor accepts integer, bigint and UUID columns. Bigints travel as
decimal strings inside the opaque cursor, so values above JavaScript's
safe-integer limit keep their precision. UUIDs stay strings and are validated
before they reach PostgreSQL.
The order column and the identifier can use different types. A bigint order column can take a UUID primary key as its stable tiebreaker:
import { bigint, pgTable, uuid } from 'drizzle-orm/pg-core'
const events = pgTable('events', {
id: uuid().defaultRandom().primaryKey(),
sequence: bigint({ mode: 'bigint' }).notNull(),
})
await withPagination({
// ...
primaryCursor: events.id,
orderBy: { column: events.sequence, order: 'asc' },
})Keep the cursor opaque on the client and hand it back unchanged in the next request.
Response Structure (pageInfo)
{
"edges": [
{ "id": 1, "title": "First post", "createdAt": "2026-01-01T00:00:00.000Z" }
],
"pageInfo": {
"totalCount": 120,
"totalPages": 12,
"currentPage": 1,
"count": 10,
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "eyJjIjoiY3Jl...",
"endCursor": "eyJjIjoiY3Jl..."
}
}Prop
Type
Frontend Integration
Move through results from a plugin page
Plugin routes normalize their own query string with parseSearch, then receive
the typed search, loaderData, and same-page navigate function. That is all
a list needs—no host route file required.
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
interface PostsSearch {
cursor?: string
first: number
}
export const route = definePluginRoute({
parseSearch: (input) => {
const search = input as Record<string, unknown>
const first = Number(search.first)
return {
cursor: typeof search.cursor === 'string' ? search.cursor : undefined,
first: Number.isInteger(first) && first > 0 ? first : 10,
}
},
load: async ({ search }) => await fetchPosts(search),
})
const PostsPage = ({
loaderData,
navigate,
search,
}: PluginRoutePageProps<
Awaited<ReturnType<typeof fetchPosts>>,
PostsSearch
>) => (
<button
disabled={!loaderData.pageInfo.hasNextPage}
onClick={() =>
void navigate({
resetScroll: false,
search: {
...search,
cursor: loaderData.pageInfo.endCursor ?? undefined,
},
})
}
type="button"
>
Next page
</button>
)
export default PostsPageCustom Filtering and Search
To add search and custom filters, pass the parameters into withPagination:
import { eq } from 'drizzle-orm'
const data = await withPagination({
c,
params: { query: c.req.valid('query') },
table: blog_posts,
primaryCursor: blog_posts.id,
orderBy: { column: blog_posts.createdAt, order: 'desc' },
search: [blog_posts.title, blog_posts.content],
where: categoryId ? eq(blog_posts.categoryId, categoryId) : undefined,
query: async ({ cursorSelection, limit, where, orderBy }) =>
await c
.get('db')
.select({ ...getColumns(blog_posts), ...cursorSelection })
.from(blog_posts)
.where(where)
.orderBy(orderBy)
.limit(limit),
})Best Practices
Opaque cursors
Cursors encode the specific column ordering. If the user changes sort order or
filter criteria, reset the cursor to undefined so pagination starts from the
first page.
Always apply callback where and orderBy
withPagination injects cursor conditions directly into the callback's
where and orderBy. Always pass those arguments straight to Drizzle's query
methods without rebuilding them.