Pagination, Search, Filtering and Sorting
Implement scalable list endpoints using query parameters, MongoDB pagination, text search, filters, sorting, and metadata.
Why API Pagination Is Important
Imagine a database containing one million users.
If the API retrieves every user in one request, it can consume significant memory, increase response time, and transfer unnecessarily large amounts of data.
Pagination divides a large result set into smaller portions.
For example:
GET /api/v1/users?page=1&limit=10
This request retrieves the first ten records.
The next request can retrieve the next ten records.
Pagination improves API usability and resource management.
Two common approaches are:
- Offset-based pagination.
- Cursor-based pagination.
We will implement offset-based pagination first because it is straightforward for beginner applications.
Pagination limits the number of records returned.
Offset-based pagination uses page and limit values.
Large datasets should not be returned in a single request.
Calculate Pagination Values
Offset-based pagination commonly accepts two query parameters:
- page: Requested page number.
- limit: Number of records per page.
The skip value is calculated using:
skip = (page - 1) * limit;
Example:
Page 1, limit 10:
skip = 0
Page 2, limit 10:
skip = 10
Page 3, limit 10:
skip = 20
Mongoose provides skip() and limit() to implement this behavior.
const page = 2;
const limit = 10;
const skip = (page - 1) * limit;
console.log(skip); // 10Calculates the number of records to skip before retrieving the requested page.
page identifies the requested page.
limit determines records per page.
skip calculates the offset.
Implement Pagination in MongoDB
Update the getAllUsers service to accept pagination options.
We will use Promise.all() to execute the record query and count query concurrently.
The count query determines how many matching documents exist, while the find query retrieves the requested page.
export async function getUsers({ page, limit }) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find()
.select('-password')
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit),
User.countDocuments()
]);
return {
users,
total,
page,
limit,
totalPages: Math.ceil(total / limit)
};
}Retrieves a page of users and the total number of matching records concurrently.
skip() applies the pagination offset.
limit() restricts the number of returned documents.
countDocuments() provides the total record count.
Implement Pagination in the Controller
Update the getAllUsers controller to read pagination parameters from req.query.
Always validate query parameters because query values arrive as strings.
We will use sensible defaults and a maximum page size to prevent excessively large requests.
export async function getAllUsers(req, res, next) {
try {
const page = Math.max(1, Number.parseInt(req.query.page, 10) || 1);
const requestedLimit = Number.parseInt(req.query.limit, 10) || 10;
const limit = Math.min(100, Math.max(1, requestedLimit));
const result = await userService.getUsers({
page,
limit
});
res.status(200).json({
success: true,
data: result.users,
pagination: {
total: result.total,
page: result.page,
limit: result.limit,
totalPages: result.totalPages,
hasNextPage: result.page < result.totalPages,
hasPreviousPage: result.page > 1
}
});
} catch (error) {
next(error);
}
}Parses pagination query parameters, enforces a maximum limit, and returns records with pagination metadata.
Query parameters are accessed through req.query.
Set sensible defaults for pagination.
Enforce a maximum page size.
Implement User Search
A search endpoint allows clients to find users using a search term.
For example:
GET /api/v1/users?search=john
The API should match users whose names or email addresses contain the search term.
MongoDB supports regular expressions for pattern matching.
However, arbitrary user-provided regular expressions can be expensive or dangerous. Escape special regex characters and restrict search length.
For large datasets, consider dedicated text indexes or a search engine such as MongoDB Atlas Search.
Case-insensitive regular-expression searches may not efficiently use ordinary indexes. Use suitable indexing or a dedicated search solution for large collections.
function escapeRegex(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export async function searchUsers(search) {
const safeSearch = escapeRegex(search);
const regex = new RegExp(safeSearch, 'i');
return User.find({
$or: [
{ name: regex },
{ email: regex }
]
}).select('-password');
}Escapes regular expression metacharacters before building a case-insensitive search query.
Search parameters allow flexible resource discovery.
Escape untrusted regular-expression input.
Choose indexes according to actual query patterns.
Implement User Filtering
Filtering allows users to retrieve records matching specific conditions.
For example:
GET /api/v1/users?isActive=true
GET /api/v1/users?role=admin
GET /api/v1/users?minAge=18&maxAge=30
The API should build MongoDB filters using explicitly supported query parameters.
Never pass the entire req.query object directly into a MongoDB query because clients may inject unexpected operators or fields.
export function buildUserFilters(query) {
const filter = {};
if (query.role && ['user', 'admin'].includes(query.role)) {
filter.role = query.role;
}
if (query.isActive === 'true') {
filter.isActive = true;
} else if (query.isActive === 'false') {
filter.isActive = false;
}
const minAge = Number(query.minAge);
const maxAge = Number(query.maxAge);
if (query.minAge || query.maxAge) {
filter.age = {};
if (Number.isInteger(minAge)) filter.age.$gte = minAge;
if (Number.isInteger(maxAge)) filter.age.$lte = maxAge;
}
return filter;
}Creates a MongoDB filter using an explicit allowlist of supported query parameters.
Build filters from explicitly supported parameters.
Convert query values to appropriate data types.
Never pass arbitrary client query objects directly into MongoDB.
Implement Sorting
Sorting allows clients to control the order in which resources are returned.
Example:
GET /api/v1/users?sortBy=createdAt&order=desc
The API should only allow sorting by predefined fields.
Allowing arbitrary sort fields can create unexpected query behavior and performance issues.
const allowedSortFields = [
'name',
'email',
'age',
'createdAt'
];
export function buildSortOptions(sortBy, order) {
const field = allowedSortFields.includes(sortBy)
? sortBy
: 'createdAt';
const direction = order === 'asc' ? 1 : -1;
return { [field]: direction };
}Allows sorting only on approved fields and converts the requested direction into MongoDB sort values.
Sorting is commonly controlled through query parameters.
Allowlist supported sort fields.
Use 1 for ascending and -1 for descending order.
Combine Search, Filtering and Pagination
A useful production-style list endpoint should support search, filters, sorting, and pagination together.
Example:
GET /api/v1/users?page=1&limit=10&search=john&role=user&sortBy=createdAt&order=desc
The request should:
- Parse query parameters.
- Validate the parameters.
- Build the MongoDB filter.
- Build the sorting options.
- Apply pagination.
- Retrieve matching records.
- Count matching records.
- Return pagination metadata.
The count query must use the same filter as the data query.
Mongoose lean() can reduce memory overhead when you do not need document methods, getters, or change tracking.
export async function listUsers({
page,
limit,
filter,
sort
}) {
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find(filter)
.select('-password')
.sort(sort)
.skip(skip)
.limit(limit)
.lean(),
User.countDocuments(filter)
]);
return {
users,
total,
page,
limit,
totalPages: Math.ceil(total / limit)
};
}Combines filtering, sorting, pagination, and counting. lean() returns plain JavaScript objects rather than full Mongoose documents, which is useful for read-only results.
Combine query features into a consistent service interface.
Apply identical filters to data and count queries.
Use lean() when full Mongoose document behavior is unnecessary.