Database
Pagination
Add stable cursor pagination — or numbered pages — to a plugin API route and navigate its results from a typed plugin page.
withPagination turns a Drizzle select into a paged list route that returns { edges, pageInfo }. It 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, the 1, 2, 3 … 38 kind that admin tables show, the same route also accepts ?page=. You do not have to choose up front, because one route serves both. In this guide the site-notes plugin gets a paged notes route and a page that steps through it.
Before you begin
- The
site-notesplugin with itssite_notestable,notesmodule andzodNoteschema from the beginner path up to Database. Any table with a unique integer, bigint or UUID id works the same way.
Quick start
Add a paginated API route
Create src/api/modules/notes/paged.route.ts. Validate the query with zodPaginationQuery, describe the response with zodPaginationPageInfo, and let withPagination build the page:
import { z } from '@hono/zod-openapi'
import {
withPagination,
zodPaginationPageInfo,
zodPaginationQuery,
} from '@vitnode/core/api/lib/with-pagination'
import { buildRoute } from '@/api/builders'
import { site_notes } from '@/database/notes'
import { zodNote } from './notes.schema'
export const pagedNotesRoute = buildRoute({
route: {
method: 'get',
path: '/paged',
request: { query: zodPaginationQuery },
responses: {
200: {
content: {
'application/json': {
schema: z.object({
edges: z.array(zodNote),
pageInfo: zodPaginationPageInfo,
}),
},
},
description: 'One page of notes, newest first.',
},
},
},
handler: async c => {
const data = await withPagination({
c,
params: { query: c.req.valid('query') },
primaryCursor: site_notes.id,
table: site_notes,
orderBy: { column: site_notes.createdAt, order: 'desc' },
query: async ({ cursorSelection, limit, offset, where, orderBy }) =>
await c
.get('db')
.select({
...cursorSelection,
id: site_notes.id,
title: site_notes.title,
body: site_notes.body,
})
.from(site_notes)
.where(where)
.orderBy(orderBy)
.limit(limit)
.offset(offset),
})
return c.json(data, 200)
},
})Add pagedNotesRoute to the routes of notesModule, before showNoteRoute (routes: [listNotesRoute, pagedNotesRoute, showNoteRoute]). Routes match in the order they are listed, so after /{id} the path /paged would be read as an id and answer 400. The route now answers GET /api/site-notes/notes/paged, accepts ?first=10&cursor=... (cursor walk) or ?page=3&first=10 (numbered page), and returns { edges, pageInfo }. The page size defaults to 50 and is capped at 100.
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:
import { camelCase, index } from 'drizzle-orm/pg-core'
export const site_notes = camelCase.table.withRLS(
'site_notes',
t => ({
// columns unchanged
}),
t => [index('site_notes_created_at_id_idx').on(t.createdAt, t.id)],
)Generate and apply a migration afterwards, as in Database.
withPagination emits the cursor predicate as a row comparison, (createdAt, id) < ($1, $2), so Postgres can seek straight to the boundary on that index. 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 (pageInfo)
{
"edges": [
{ "id": 12, "title": "Welcome", "body": "Our first note." }
],
"pageInfo": {
"totalCount": 120,
"totalPages": 12,
"currentPage": 1,
"pageSize": 10,
"count": 10,
"hasNextPage": true,
"hasPreviousPage": false,
"startCursor": "eyJjIjoiY3Jl...",
"endCursor": "eyJjIjoiY3Jl..."
}
}Prop
Type
Step through pages in the browser
Create the page
A plugin page parses its own query string with parseSearch, loads data with load, and receives the typed search, loaderData and a same-page navigate function. The loader calls the route with fetcher, passing the cursor as a query parameter:
import type { PluginRoutePageProps } from '@vitnode/core/routing'
import { definePluginRoute } from '@vitnode/core/routing'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
import { manifest } from '@/manifest.gen'
interface BrowseSearch {
cursor?: string
}
const loadPage = async (search: BrowseSearch) => {
const response = await fetcher({
plugin: manifest.id,
method: 'get',
module: 'notes',
path: '/paged',
args: { query: { first: '10', cursor: search.cursor } },
})
if (!response.ok) throw new Error('Could not load notes.')
return await response.json()
}
type BrowseData = Awaited<ReturnType<typeof loadPage>>
export const route = definePluginRoute<BrowseData, BrowseSearch>({
parseSearch: input => {
const { cursor } = input as Record<string, unknown>
return { cursor: typeof cursor === 'string' ? cursor : undefined }
},
load: async ({ search }) => await loadPage(search),
})
const BrowsePage = ({
loaderData,
navigate,
search,
}: PluginRoutePageProps<BrowseData, BrowseSearch>) => (
<main className="container mx-auto flex max-w-2xl flex-col gap-4 p-4">
<ul className="flex flex-col gap-2">
{loaderData.edges.map(note => (
<li key={note.id}>{note.title}</li>
))}
</ul>
<button
disabled={!loaderData.pageInfo.hasNextPage}
onClick={() =>
void navigate({
resetScroll: false,
search: { ...search, cursor: loaderData.pageInfo.endCursor ?? undefined },
})
}
type="button"
>
Next page
</button>
</main>
)
export default BrowsePageRegister the page
Add the page to the plugin's src/routes.ts:
page('/site-notes/browse', {
component: lazy(() => import('./pages/browse-page')),
}),Check the result
Open /site-notes/browse. The page lists up to 10 notes, newest first. With more than 10 notes, Next page adds ?cursor=... to the URL and shows the next 10; on the last page the button is disabled.
Search and custom filters
withPagination filters with search (text columns matched case-insensitively against ?search=) and where (any extra condition). Neither is in zodPaginationQuery, so extend the query schema with the parameters your route accepts:
import { gte } from 'drizzle-orm'
// in route.request
query: zodPaginationQuery.extend({
search: z.string().max(100).optional(),
since: z.iso.datetime().optional(),
}),
// in the handler
const query = c.req.valid('query')
const data = await withPagination({
c,
params: { query },
table: site_notes,
primaryCursor: site_notes.id,
orderBy: { column: site_notes.createdAt, order: 'desc' },
search: [site_notes.title, site_notes.body],
where: query.since ? gte(site_notes.createdAt, new Date(query.since)) : undefined,
query: async ({ cursorSelection, limit, offset, where, orderBy }) =>
await c
.get('db')
.select({
...cursorSelection,
id: site_notes.id,
title: site_notes.title,
body: site_notes.body,
})
.from(site_notes)
.where(where)
.orderBy(orderBy)
.limit(limit)
.offset(offset),
})GET /api/site-notes/notes/paged?search=welcome now returns only notes whose title or body contains "welcome". totalCount counts the filtered rows.
Best practices
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.
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.