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:

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, 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

MetricLIMIT ... OFFSETKeyset Cursor
Insert / Delete mid-walkRows shift, duplicate, or skipUnaffected (stable pointer)
Deep pagination (Page 10,000)Full table scan up to offsetSingle indexed B-tree lookup
Sort order stabilityProne to non-deterministic tiesGuaranteed 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.

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