Pagination
Add stable cursor pagination to a plugin API route and navigate its results from a typed plugin page.
VitNode uses cursor-based pagination rather than offset pagination. Cursors ensure stable page boundaries even while records are created or deleted, with zero database performance degradation on large tables.
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, where, orderBy }) =>
await c
.get('db')
.select({ ...getColumns(blog_posts), ...cursorSelection })
.from(blog_posts)
.where(where)
.orderBy(orderBy)
.limit(limit),
})
return c.json(data)
},
})The route automatically accepts ?cursor=...&first=10 and returns { edges, pageInfo }.
Why Cursors Over Offsets
| Metric | LIMIT ... OFFSET | Keyset Cursor |
|---|---|---|
| Insert / Delete mid-walk | Rows shift, duplicate, or skip | Unaffected (stable pointer) |
| Deep pagination (Page 10,000) | Full table scan up to offset | Single indexed B-tree lookup |
| Sort order stability | Prone to non-deterministic ties | Guaranteed by primaryCursor tiebreaker |
withPagination Options
Prop
Type
Response Structure (pageInfo)
{
"edges": [
{ "id": 1, "title": "First post", "createdAt": "2026-01-01T00:00:00.000Z" }
],
"pageInfo": {
"totalCount": 120,
"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.