Now that our app's "front door" is secured with Zod validation, ensuring no bad data enters the kitchen, it's time to talk about the menu.
So far, when we wanted to retrieve data, we simply wrote:
const users = await User.find();In a development environment with 5 test users, this works perfectly. But imagine trying to run this command when your app has 100,000 users. Your database will struggle to fetch them all, your server will run out of memory trying to hold them in the response, and your user's browser will freeze trying to display them.
When dealing with real data, you never send everything at once. You serve it in manageable chunks.
Today, we’re moving beyond .find() and learning the holy trinity of data retrieval: Pagination, Filtering, and Sorting.
URL Query Parameters: The Customer's Instructions
Before we talk to the database, we need to know what the client is asking for. If HTTP Methods (like GET or POST) are the broad actions, and the URL (/users) is the location, then Query Parameters are the specific instructions.
Query parameters live at the end of a URL, starting with a ? and separated by &.
GET /users?role=admin&page=2&sort=-createdAtExpress makes these magically available inside your controllers as an object called req.query:
console.log(req.query);
// Output: { role: 'admin', page: '2', sort: '-createdAt' }Let's build a robust controller that uses these instructions.
1. Filtering: Finding the Needle
Filtering allows users to search for specific subsets of data. Instead of all users, maybe they only want Admins, or users who signed up recently.
In Mongoose, .find() takes an object that acts as a filter.
export const getUsers = asyncHandler(async (req, res) => {
// Start with whatever queries the user sent
const queryObj = { ...req.query };
// WAIT! There are some queries that aren't for filtering (like sort and page)
// We need to remove them before talking to the database
const excludedFields = ['page', 'sort', 'limit', 'fields'];
excludedFields.forEach(el => delete queryObj[el]);
// Now queryObj only contains actual filters (like { role: 'admin' })
let query = User.find(queryObj);
const users = await query;
return res.status(200).json(new ApiResponse(200, users, "Users found"));
});Advanced Filtering (GreaterThan / LessThan)
What if a user searches /users?age[gte]=18 (meaning "Age Greater Than or Equal to 18")?
Mongoose expects it to look like: { age: { $gte: 18 } }. Notice the $ symbol?
We can write a quick hack to add the $ sign wherever it's needed:
let queryStr = JSON.stringify(queryObj);
// Replace gte, gt, lte, lt with $gte, $gt, etc.
queryStr = queryStr.replace(/\\b(gte|gt|lte|lt)\\b/g, match => `$${match}`);
let query = User.find(JSON.parse(queryStr));2. Sorting: Organizing the Stack
What if the user wants the newest users first? They might send ?sort=-createdAt (the minus sign commonly denotes descending order).
Mongoose .sort() methods handle this natively.
if (req.query.sort) {
// If multiple sorts are requested (e.g. ?sort=role,name), Mongoose expects spaces instead of commas.
const sortBy = req.query.sort.split(',').join(' ');
query = query.sort(sortBy);
} else {
// Default sorting: Newest first
query = query.sort('-createdAt');
}3. Pagination: Serving Chunks (The Big One)
This is the most critical piece. Pagination limits the number of results returned and allows the user to say "Give me page 2."
Mongoose uses two functions for this:
.limit(): How many results maximum should I return?.skip(): How many results should I ignore from the top before I start counting?
If we have 10 results per page, and we want Page 3, we need to skip the first 20 results (Pages 1 and 2), and limit the response to 10.
// Convert string queries to numbers, set defaults
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
// The Math: If page 3, (3 - 1) * 10 = skip 20.
const skip = (page - 1) * limit;
query = query.skip(skip).limit(limit);
// Edge Case: What if they request a page that doesn't exist?
if (req.query.page) {
const numUsers = await User.countDocuments();
if (skip >= numUsers) throw new ApiError(404, "This page does not exist");
}Putting It All Together
Let's view the final, beautifully resilient controller function that can handle millions of records gracefully:
export const getUsers = asyncHandler(async (req, res) => {
// 1. FILTERING
const queryObj = { ...req.query };
const excludedFields = ['page', 'sort', 'limit', 'fields'];
excludedFields.forEach(el => delete queryObj[el]);
let queryStr = JSON.stringify(queryObj);
queryStr = queryStr.replace(/\\b(gte|gt|lte|lt)\\b/g, match => `$${match}`);
let query = User.find(JSON.parse(queryStr));
// 2. SORTING
if (req.query.sort) {
const sortBy = req.query.sort.split(',').join(' ');
query = query.sort(sortBy);
} else {
query = query.sort('-createdAt');
}
// 3. PAGINATION
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
query = query.skip(skip).limit(limit);
// Execute the query!
const users = await query;
// Get total count for frontend pagination UI
const total = await User.countDocuments(JSON.parse(queryStr));
return res.status(200).json(
new ApiResponse(200, {
count: users.length,
total,
page,
totalPages: Math.ceil(total / limit),
users
}, "Users fetched successfully")
);
});Why the Meta-Data Matters
Notice the response payload. We aren't just sending { users } anymore. We are sending total, page, and totalPages.
The frontend needs this metadata. If you don't tell the frontend application how many total pages exist, they won't know when to disable the "Next Page" button!
Summary
You've taken a massive step toward building production-grade APIs.
- Filtering narrows down the dataset.
- Sorting arranges it.
- Pagination chunks it into safe, digestible pieces.
Up until now, our data has been completely isolated. A User exists in a vacuum. But real apps are interconnected: Users create Posts, Users leave Comments, Users belong to Teams.
In the next post, we will finally bridge the gap by learning Relational Data Mapping in MongoDB using References and .populate(), building a complex architecture that mimics real-world apps.
Happy coding!