@evensurface/paginate is a small, type-safe page, offset, and cursor pagination library for
Node.js and TypeScript. It supports counted navigation and genuine count-free keyset traversal.
Use the core package with any database or ORM, or add a first-party adapter for Drizzle ORM or Better Auth.
The
@evensurfacenpm scope and theevensurface/paginaterepository are planned placeholders and are not live yet.
| Package | Use it when | Documentation |
|---|---|---|
@evensurface/paginate |
You want ORM-agnostic pagination utilities or a custom adapter | Core guide |
@evensurface/paginate-drizzle |
You use Drizzle ORM and want inferred row types | Drizzle guide |
@evensurface/paginate-better-auth |
You use a Better Auth 1.7.x database adapter | Better Auth guide |
- Parses page, native-offset, and page-size values from URLs, forms, and API requests
- Clamps page sizes to configurable minimum and maximum limits
- Calculates safe SQL-style
limitandoffsetvalues - Returns totals, page counts, item ranges, and next/previous-page flags
- Resolves out-of-range pages to the last page and preserves out-of-range native offsets
- Generates compact navigation such as
1 … 4 5 6 … 20 - Preserves item types through generic and Drizzle-specific adapters
- Uses opaque typed cursors and
limit + 1lookahead for keyset pagination
Use paginate() for numbered pages, paginateOffset() for a zero-based offset, or
paginateCursor() for forward-only keyset pagination. Cursor mode uses a separate adapter and does
not execute a count query. The library does not generate routes, render pagination UI, or prescribe
a web framework.
Install the core package:
pnpm add @evensurface/paginateCreate an adapter for your data source and paginate a query:
import { paginate, type PaginationAdapter } from "@evensurface/paginate"
type User = { id: number; name: string }
type UserFilter = { active: boolean }
const adapter: PaginationAdapter<UserFilter, UserFilter, User> = {
count: (filter) => database.users.count(filter),
fetch: (filter, { limit, offset }) =>
database.users.findMany({ ...filter, limit, offset, orderBy: { id: "asc" } }),
}
const result = await paginate({
adapter,
query: { active: true },
countQuery: { active: true },
page: request.query.page,
limit: request.query.limit,
})The result contains the selected items and frontend-ready metadata:
{
items: [/* typed User records */],
pagination: {
page: 2,
limit: 20,
offset: 20,
total: 95,
totalPages: 5,
hasNextPage: true,
hasPreviousPage: true,
firstItem: 21,
lastItem: 40,
},
}pnpm add @evensurface/paginate @evensurface/paginate-drizzle drizzle-ormimport { desc, eq } from "drizzle-orm"
import { paginate } from "@evensurface/paginate-drizzle"
import { db } from "./db"
import { users } from "./schema"
const where = eq(users.active, true)
const result = await paginate({
query: () => db.select().from(users).where(where).orderBy(desc(users.id)),
countQuery: db.$count(users, where),
page: request.query.page,
limit: request.query.limit,
})See the Drizzle pagination guide for filtered queries, joins, count queries, stable ordering, and page-size configuration.
| Input or situation | Behavior |
|---|---|
Missing or invalid page |
Uses page 1 |
Missing or invalid limit |
Uses 20 |
limit below 1 or above 100 |
Clamps valid integers to the allowed range |
| Requested page exceeds the result set | Returns the last available page |
| Query has no matching records | Returns no items and skips the fetch query |
All defaults are configurable. Read the complete core package documentation for API examples and customization.
Offset pagination is a good fit for admin tables, search results, directories, and other interfaces where users expect numbered pages and direct navigation. For very large or rapidly changing feeds, cursor pagination may provide more stable performance and ordering.
The tradeoff is that every paginated request performs a count before fetching rows, deep offsets can become increasingly expensive, and separate count/fetch operations can observe different data when records change concurrently. The application must also provide deterministic ordering and ensure the count and fetch use equivalent filters. See the package guides for the complete operational considerations.
- Published packages: Node.js 18.18 or newer
- Repository development: Node.js 22.18 or newer and pnpm 11.15.0
This repository is a pnpm workspace managed with Turborepo. Package source lives under packages/.
pnpm install --frozen-lockfile
pnpm check
pnpm typecheck
pnpm test
pnpm buildMIT