Utils to generate cursor based pagination for drizzle-orm
| ⚡ Supports any number of cursors. |
|---|
Check example at: test/example.ts
npm install drizzle-cursor- Drizzle
0.xquery-builder: guaranteed - Drizzle
0.xrelational query (RQB v1):db.querywith SQL-compatible args - Drizzle
1.0.0-beta.xquery-builder: guaranteed - Drizzle
1.0.0-beta.xRQB v1 (db._query) and RQB v2 (db.query): supported
Notes:
- Canonical cross-version path: query-builder
(
db.select().from(...).where(cursor.where(...)).orderBy(...cursor.orderBy)). cursor.relationsis only for RQB v2 (db.query) and usescursor.keynames as relational keys.- Nullable cursor columns remain discouraged due database and driver consistency differences.
Note
Check types at TSDocs
First create a cursor with generateCursor listing the Primary-key and the n-other cursors.
Warning
The order of the cursors matters because it's the way they are going to take in account on the generated SQL-query
Is not recommended to use nullable-columns for your cursors, it depends on your RDBMS how it handles them.
const cursorConfig: CursorConfig = {
cursors: [
{ order: "ASC", key: "lastName", schema: schema.users.lastName },
{ order: "ASC", key: "firstName", schema: schema.users.firstName },
{ order: "ASC", key: "middleName", schema: schema.users.middleName },
],
primaryCursor: { order: "ASC", key: "id", schema: schema.users.id },
};
const cursor = generateCursor(cursorConfig);Pass ...cursor.orderBy to .orderBy and cursor.where() to .where on query-builder calls.
Important
for the first batch of results cursor.where() is empty,
const page1 = await db
.select({
lastName: schema.users.lastName,
firstName: schema.users.firstName,
middleName: schema.users.middleName,
id: schema.users.id,
})
.from(schema.users)
.orderBy(...cursor.orderBy) // Always include the order
.where(cursor.where()) // .where() is called empty the first time, meaning "there's not previous records"
.limit(page_size);For the subsequent queries you can send the last previous record on cursor.where
const page2 = await db
.select() // .select() can vary while it includes the needed data to create the cursor
.from(schema.users)
.orderBy(...cursor.orderBy)
.where(cursor.where(page1.at(-1))) // last record of previous query (or any record "before: the one you want to start with)
.limit(page_size);or a token from the last item (useful to send to FE)
const token = cursor.serialize(page2.at(-1)); // Send this string to FE
const pageFromToken = await db
.select({
lastName: schema.users.lastName,
firstName: schema.users.firstName,
middleName: schema.users.middleName,
id: schema.users.id,
})
.from(schema.users)
.orderBy(...cursor.orderBy)
.where(cursor.where(token)) // parse() is already handled internally by cursor.where
.limit(page_size);const page1V0 = await db.query.users.findMany({
columns: {
lastName: true,
firstName: true,
middleName: true,
id: true,
},
orderBy: cursor.orderBy,
where: cursor.where(),
limit: page_size,
});
const page2V0 = await db.query.users.findMany({
columns: {
lastName: true,
firstName: true,
middleName: true,
id: true,
},
orderBy: cursor.orderBy,
where: cursor.where(cursor.serialize(page1V0.at(-1))),
limit: page_size,
});const page1V1 = await db._query.users.findMany({
columns: {
lastName: true,
firstName: true,
middleName: true,
id: true,
},
orderBy: cursor.orderBy,
where: cursor.where(),
limit: page_size,
});
const page2V1 = await db._query.users.findMany({
columns: {
lastName: true,
firstName: true,
middleName: true,
id: true,
},
orderBy: cursor.orderBy,
where: cursor.where(cursor.serialize(page1V1.at(-1))),
limit: page_size,
});const page1V2 = await db.query.users.findMany({
columns: {
lastName: true,
firstName: true,
middleName: true,
id: true,
},
orderBy: cursor.relations.orderBy,
where: cursor.relations.where(),
limit: page_size,
});
const page2V2 = await db.query.users.findMany({
columns: {
lastName: true,
firstName: true,
middleName: true,
id: true,
},
orderBy: cursor.relations.orderBy,
where: cursor.relations.where(cursor.serialize(page1V2.at(-1))),
limit: page_size,
});generateCursor uses cursor.where() exactly for this rule:
cursor.where()with no args means first page → returnsundefined.cursor.where(...)with a previous record (object) or a cursor string (token) means next page.
Important: only keys in your cursor definition (primaryCursor + cursors) matter.
Extra keys in rows are ignored.
const firstPage = await db
.select({
lastName: schema.users.lastName,
firstName: schema.users.firstName,
middleName: schema.users.middleName,
id: schema.users.id,
})
.from(schema.users)
.orderBy(...cursor.orderBy)
.where(cursor.where()) // No args => first page.
.limit(page_size);
const page2FromObject = await db
.select({ id: schema.users.id })
.from(schema.users)
.orderBy(...cursor.orderBy)
.where(cursor.where(firstPage.at(-1))) // Previous row object.
.limit(page_size);
const token = cursor.serialize(firstPage.at(-1));
const page2FromToken = await db
.select({ id: schema.users.id })
.from(schema.users)
.orderBy(...cursor.orderBy)
.where(cursor.where(token)) // Token string from previous page - MOCK EXAMPLE you in reality would get from the API request.
.limit(page_size);If the default JSON.stringify/JSON.parse pipeline is not enough, pass parser and serializer to generateCursor:
parser: converts the decoded payload into a JavaScript object.serializer: converts your payload object into a string before encode.
Parser/serializer options (payload layer):
- superjson
- devalue
- msgpackr
- flatted
- yaml / js-yaml
- toml
- JSON5
- zipson
- ZON
- tron
- toon
- csv
- etc (
serializer: (value: T) => string,parser: (value: string) => T)
Example (from the extended tests):
import { generateCursor } from "drizzle-cursor";
import { parse, stringify } from "superjson";
const cursor = generateCursor(
{
primaryCursor: { key: "id", schema: users.id, order: "ASC" },
cursors: [{ key: "slug", schema: users.slug, order: "ASC" }],
},
{
serializer: (value) => `cur_${stringify(value)}`,
parser: (value) => parse(value.slice(4)),
},
);
const token = cursor.serialize({ id: 1, slug: "slug-01" });
const parsed = cursor.parse(token);Use encoder and decoder when you need to post-process the full token string
(prefix, URL-safe base encoding, encryption, obfuscation, compression, etc.).
encoder: transforms the serialized payload into the final token string.decoder: restores the serialized payload forparser.
Encoder/decoder options (token layer):
base64url/base-x(bun.toBase64,Buffer,base-xalphabets).- base-x
AES(crypto.createCipheriv/createDecipheriv).- URL-safe wrappers and signatures (HMAC/JWT-like) to prevent tampering.
- custom prefixing/suffixing and checksums.
- base64-js
- base64url
- tweetnacl (for signing/encrypt-like workflows)
- etc (
encoder: (value: string) => string,decoder: (value: string) => string)
generateCursor second argument shape (options):
type CursorRecord = Record<string, unknown>;
type CursorOptions<T extends CursorRecord = CursorRecord> = {
decoder?: (value: string) => string;
encoder?: (value: string) => string;
parser?: (value: string) => T;
serializer?: (value: T) => string;
parse?: (cursor: string | null) => T | null;
serialize?: (data?: T | null) => string | null;
};Notes:
parser,serializer,encoder,decoderare usually enough to customize the token pipeline.parseandserializeare advanced overrides (rarely needed) that replace the full internals:serialize: build the full cursor token.parse: read and validate the full cursor token.
- In 99% of cases, prefer
parser/serializer(+ optionalencoder/decoder) and keepparse/serializeas defaults.
import { generateCursor } from "drizzle-cursor";
import BaseX from "base-x";
const prefix = "cur_";
const baseX = BaseX("123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz");
const encoder = (value: string) => `${prefix}${baseX.encode(Buffer.from(value, "utf-8"))}`;
const decoder = (value: string) => {
if (!value.startsWith(prefix)) {
throw new Error("Invalid cursor token");
}
return Buffer.from(baseX.decode(value.slice(prefix.length))).toString("utf-8");
};
const cursor = generateCursor(
{
primaryCursor: { key: "id", schema: users.id, order: "ASC" },
cursors: [{ key: "slug", schema: users.slug, order: "ASC" }],
},
{ encoder, decoder },
);
const token = cursor.serialize({ id: 1, slug: "slug-01" });
const parsed = cursor.parse(token);Instead of a table column (schema), you can use a raw Drizzle sql expression as a cursor. This is useful for sorting by computed or virtual values like case-insensitive names, concatenated fields, or any expression your database can evaluate.
import { sql } from "drizzle-orm";
import { generateCursor } from "drizzle-cursor";
const rankUpperName = sql<string>`${users.rank}::text || '-' || upper(${users.firstName})`;
const cursor = generateCursor({
primaryCursor: { key: "id", schema: users.id, order: "ASC" },
cursors: [
{ key: "rankUpperName", sql: rankUpperName, order: "ASC" },
],
});The key must match a field in the result row so the token pipeline can read its value for pagination. Include the expression in your select to make it available:
const page1 = await db
.select({
id: users.id,
firstName: users.firstName,
rankUpperName, // same sql expression — makes it available in the row
})
.from(users)
.orderBy(...cursor.orderBy)
.where(cursor.where())
.limit(page_size);
const page2 = await db
.select({ id: users.id, firstName: users.firstName, rankUpperName })
.from(users)
.orderBy(...cursor.orderBy)
.where(cursor.where(cursor.serialize(page1.at(-1))))
.limit(page_size);When any cursor uses sql, cursor.relations.orderBy becomes a () => SQL[] callback instead of a plain Record<string, "asc" | "desc"> — pass it directly to the RQB v2 orderBy option:
const page1 = await db.query.users.findMany({
orderBy: cursor.relations.orderBy, // () => SQL[] when SQL cursors are present
where: cursor.relations.where(),
limit: page_size,
});Note
cursor.relations.where() with SQL expression cursors produces { RAW: ... } conditions
that work correctly in RQB v2 when the SQL expression does not reference the table through
Drizzle's aliased context. For full portability across all query modes, prefer the query-builder
path (cursor.where()) or RQB v1 (db._query) when paginating by SQL expressions.
TableCursor and SQLCursor are exported for user-facing type annotations:
import type { CursorConfig, SQLCursor, TableCursor } from "drizzle-cursor";
const config: CursorConfig<SQLCursor> = {
primaryCursor: { key: "id", sql: sql`${users.id}`, order: "ASC" },
};Submit an Issue with a minimal reproducible example.
PRs are welcome
Maintainers: release and prerelease workflow lives in CONTRIBUTING.md.
MIT / Do whatever you want.