Skip to main content

Pagination

Lesan's find returns a MongoDB cursor, and its aggregation accepts raw pipeline stages โ€” so you have full control over how you paginate. There's no built-in pagination API; you compose it from the primitives.

Offset Pagination (page-based)โ€‹

The classic approach: skip the documents before the current page, limit to the page size. Because find returns a cursor, you chain .skip() and .limit() directly:

const getCountries: ActFn = async (body) => {
let { set: { page, limit }, get } = body.details;

page = page || 1;
limit = limit || 50;
const skip = limit * (page - 1);

return await countries
.find({ projection: get, filters: {} })
.skip(skip)
.limit(limit)
.toArray();
};

A matching validator:

const getCountriesValidator = () =>
object({
set: object({ page: number(), limit: number() }),
get: coreApp.schemas.selectStruct("country", {
citiesByPopulation: 1,
users: 1,
capital: 1,
}),
});

With a sortโ€‹

Always pair offset pagination with a stable sort, or pages will shift when documents change:

await users
.find({ projection: get, filters: {} })
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.toArray();

Returning total countโ€‹

const [items, total] = await Promise.all([
users.find({ filters }).skip(skip).limit(limit).toArray(),
users.countDocument({ filter: filters }),
]);
return { items, total, page, limit };

Offset Pagination Inside Aggregationโ€‹

When you need pipeline logic (filters, grouping), use $skip and $limit stages:

const getCitiesAggregation: ActFn = async (body) => {
const { set: { page, take, countryId }, get } = body.details;
const pipeline: any[] = [];

pipeline.push({ $skip: (page - 1) * take });
pipeline.push({ $limit: take });

if (countryId) {
pipeline.push({ $match: { "country._id": new ObjectId(countryId) } });
}

return await cities.aggregation({ pipeline, projection: get }).toArray();
};

Cursor-Based Pagination (keyset)โ€‹

For large datasets, cursor pagination is more efficient and stable: instead of skipping from the start, you filter on a monotonic key relative to the last item seen. The _id (a MongoDB ObjectId) is the natural cursor.

const getMessages: ActFn = async (body) => {
const { lastId, limit = 20 } = body.details.set;

const filters: Record<string, unknown> = {};
if (lastId) filters._id = { $lt: new ObjectId(lastId) }; // previous page's last _id

return await messages
.find({ filters, projection: body.details.get })
.sort({ _id: -1 })
.limit(limit)
.toArray();
};

The client sends the _id of the last item from the previous page as lastId; the server returns the next limit items after it. No skipping, no page drift, and the cost is the same regardless of page depth.

When to use whichโ€‹

Offset (skip/limit)Cursor (_id-keyset)
UXJump to any pageForward-only ("load more")
StabilityPages shift if data changesAlways stable
Deep pagesSlow (skips everything)Constant-time
Total countEasyNeeds a separate count

Notesโ€‹

  • find also accepts options?: FindOptions, so you can pass sort, limit, and skip there too: find({ filters, options: { sort: { _id: -1 }, limit: 20 } }).
  • Combine pagination with filtering freely โ€” the two compose.