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:

plugins/blog/src/api/modules/posts/routes/get.route.ts
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-walkRows shift, duplicate, or skipUnaffected (stable pointer)
Deep pagination (page 10,000)Walks every skipped rowSingle indexed B-tree lookup
Sort order stabilityProne to non-deterministic tiesGuaranteed by primaryCursor tiebreaker
Jump to an arbitrary pageYesNo — you can only step
"Page 4 of 38"YesNo — 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:

plugins/blog/src/database/posts.ts
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.

plugins/blog/src/pages/posts-page.tsx
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 PostsPage

To add search and custom filters, pass the parameters into withPagination:

plugins/blog/src/api/modules/posts/routes/get.route.ts
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.

Learn More