Pagination
Keyset pagination of a MongoDB collection with mz.after, built from the Zod schema and the sort, or offset pagination with skip and limit.
Each page is a new query. For infinite scroll and "next" buttons, mz.after turns the last
document of a page into the filter of the next one, the range queries that the MongoDB manual
recommends over skip. For tables with numbered pages, skip and limit work as in the driver.
import { ObjectId } from 'mongodb'
import { mongozod } from 'mongozod'
import * as mz from 'mongozod/mzod'
import * as z from 'zod'
const postSchema = z.object({
_id: mz.objectId()
.default(() => new ObjectId()),
title: z.string()
.min(1),
status: z.enum(['draft', 'published'])
.default('draft'),
tags: z.string()
.array()
.default([]),
createdAt: mz.date(),
updatedAt: mz.date()
})
const PostsModel = mongozod({
collection: 'posts',
db,
schema: postSchema,
timestamps: true,
indexes: (index) => [
index({ createdAt: -1, _id: -1 })
]
})
const listPostsInput = z.object({
after: mz.after(postSchema, { createdAt: -1, _id: -1 }),
options: z
.object({
limit: z.number()
.int()
.min(1)
.max(100)
.default(20)
})
.prefault({})
})
export async function listPosts(body: unknown) {
const { after, options } = listPostsInput.parse(body)
return PostsModel.find(after, { ...options, sort: { createdAt: -1, _id: -1 } })
}The first request sends no after. Each next one sends the sort values of the last post it got,
as JSON gives them:
{ "after": { "createdAt": "2026-10-12T09:00:00.000Z", "_id": "6a0c3f9e1d2a34b5c6d7e8f1" } }Why not the driver's cursor
The driver's cursor lives on the MongoDB server, which closes it when it sits idle, and with several instances of your server the next request may reach another one. A page has to be a query of its own.
With skip, the server scans from the start of the results on every page, so
skip() gets slower as the offset grows.
A range query starts where the last page ended and uses the index of the sort.
What mz.after builds
mz.after(schema, sort) takes the sort of the list. The client sends the value of each sort field
of the last document, and the router gets the filter of the documents after it, in that order:
const postsAfter = mz.after(postSchema, { createdAt: -1, title: 1, _id: -1 })
postsAfter.parse({
createdAt: '2026-10-12T09:00:00Z',
title: 'Hello, MongoDB',
_id: '6a0c3f9e1d2a34b5c6d7e8f1'
})
// { $or: [
// { createdAt: { $lt: 2026-10-12T09:00:00.000Z } },
// { createdAt: 2026-10-12T09:00:00.000Z, title: { $gt: 'Hello, MongoDB' } },
// { createdAt: 2026-10-12T09:00:00.000Z, title: 'Hello, MongoDB', _id: { $lt: ObjectId('6a0c…') } }
// ] }A document comes after the last one when it equals it on the first fields of the sort and passes it
on the next one: with $lt on a field sorted with -1, and with $gt on one sorted with 1.
Each value is parsed with the schema of its field. With
mz.date() and mz.objectId(), the ISO string and the hex string that JSON
gives become a Date and an ObjectId, so the client sends back what it received. The input takes
exactly the fields of the sort: a missing one, or one more, fails.
The sort
mz.after checks the sort when it is declared, because MongoDB would otherwise repeat or skip
documents between pages:
- It ends with
_id, a unique field that breaks ties. Without one, documents with the samecreatedAtcould land on two pages or on none. The types ask for_id, but they don't see the order of the keys of an object, so the runtime checks that it comes last. - Its fields are required and not nullable. MongoDB
sorts a missing or
nullvalue before any other, and a$lton a date never reaches it, so those documents would never show up. - Its fields hold one value, outside arrays. MongoDB sorts an array by one of its elements, and a range matches it when any element passes, so a document could show up twice or never.
- Each direction is
1or-1.
What breaks a rule doesn't compile, and throws an Error when the schema is built:
// @ts-expect-error: the sort ends with _id
mz.after(postSchema, { createdAt: -1 })
// @ts-expect-error: "tags" is an array: mz.after sorts by fields of one value
mz.after(postSchema, { tags: 1, _id: 1 })mz.after(postSchema, { _id: -1, createdAt: -1 }) compiles and throws
mz.after: the sort ends with _id, which breaks ties.
The find sorts the same way, and nothing checks that the two match. Keep the sort in one constant:
const newestFirst = { createdAt: -1, _id: -1 } as const
const feedInput = z.object({
after: mz.after(postSchema, newestFirst),
options: z
.object({
limit: z.number()
.int()
.min(1)
.max(100)
.default(20)
})
.prefault({})
})
export async function feed(body: unknown) {
const { after, options } = feedInput.parse(body)
return PostsModel.find(after, { ...options, sort: newestFirst })
}Declare an index with the keys of the sort, such as index({ createdAt: -1, _id: -1 }), and run
sync(): the sort and the ranges use it.
With other filters
mz.after gives a filter, so the router joins it with the client's filter and its own scope with
$and:
const publishedInput = z.object({
after: mz.after(postSchema, newestFirst),
filter: mz
.filter(postSchema, {
tags: ['$in']
})
.default({}),
options: z
.object({
limit: z.number()
.int()
.min(1)
.max(100)
.default(20)
})
.prefault({})
})
export async function listPublished(body: unknown) {
const { after, filter, options } = publishedInput.parse(body)
return PostsModel.find(
{ $and: [filter, after, { status: 'published' }] },
{ ...options, sort: newestFirst }
)
}A client that sends no after gets {}, the first page, so the router needs no fallback. See
Endpoint inputs for a complete list endpoint.
The key of the input is yours. With tRPC, call it cursor: its
useInfiniteQuery needs a procedure that
takes a cursor input.
Numbered pages
For a table where the user jumps to page 7, use skip and limit. They are options of the driver,
so the input takes them in options, next to the sort of mz.sort,
and the router passes them to find as they are. The client computes the skip of a page, such as
pageIndex * pageSize:
const tableInput = z.object({
options: z
.object({
limit: z.number()
.int()
.min(1)
.max(100)
.default(20),
skip: z.number()
.int()
.min(0)
.max(10_000)
.default(0),
sort: mz
.sort(postSchema, {
createdAt: true,
title: true
})
.default({ createdAt: -1 })
})
.prefault({})
})
export async function postsTable(body: unknown) {
const { options } = tableInput.parse(body)
const [rows, total] = await Promise.all([
PostsModel.find({}, options),
PostsModel.countDocuments({})
])
return { rows, total }
}The .max() of skip matters: MongoDB reads every document a skip passes over, so a client that
asks for a skip of 1,000,000 makes the server read that many documents.
The cursor of find also chains them, as the driver's does:
PostsModel.find({}).sort(newestFirst).skip(40).limit(20).
Besides getting slower as the offset grows, skip counts positions: a post published or deleted
while the user pages shifts the later pages by one, so a row repeats or goes missing. A range query
starts after a document, so the others don't move it.
Common mistakes
- Sorting the
finddifferently frommz.after: the pages repeat or skip documents, without an error. Share one constant between both. - Letting the client choose the sort of a keyset list: the sort of
mz.afteris fixed when the endpoint declares it, so a client'smz.sortthat differs from it breaks the pages. Declare one list per order, or useskipfor a table the client sorts. - Sorting by an optional or nullable field: it doesn't compile, and throws. Make the field
required and not nullable, with a
.default()if writes may leave it out. - Declaring the date as
z.date(): the ISO string the client sends back fails. Usemz.date(). - Sending the whole document as
after: the input takes only the fields of the sort, so the others fail. Send{ createdAt, _id }. - Using
skipfor infinite scroll: it gets slower with each page, and new documents shift the pages. Usemz.after.
Last updated on
MongoZod is an independent open-source project. It is not affiliated with, endorsed by or sponsored by MongoDB, Inc. MongoDB® is a registered trademark of MongoDB, Inc.
Security
How the endpoint inputs of MongoZod keep NoSQL operator injection out when an endpoint takes a MongoDB query from a client, and what stays with your application.
Populate
Declare the references between MongoDB models and replace the stored ids with the documents they point to, with a typed populate.