Pagination, Filtering, Sorting, and Full-Text Search

Every real-world API that returns collections needs pagination, filtering, and sorting. Without pagination, a single request could return thousands of documents, crashing the client and overloading the database. Without filtering, clients download everything and filter client-side โ€” wasteful and slow. Without consistent sorting, results appear in unpredictable order. These three features โ€” along with search โ€” are the difference between a toy API and a professional one. This lesson builds a complete, reusable query builder that handles all four for any Express + MongoDB API endpoint.

Pagination Strategies

Strategy How It Works Pros Cons
Offset / Page skip(page*limit).limit(limit) Jump to any page, total count easy Slow on deep pages, items shift if data changes
Cursor / Keyset find({ _id: { $gt: lastId } }).limit(n) Fast regardless of depth, stable No random page access, total count harder
Infinite Scroll Cursor-based, load more on scroll Great UX for feeds No page numbers

Pagination Response Meta Fields

Field Type Description
total number Total documents matching the filter
page number Current page number (1-indexed)
limit number Documents per page
totalPages number Math.ceil(total / limit)
hasNextPage boolean page < totalPages
hasPrevPage boolean page > 1

MongoDB Sort Syntax

Query String Mongoose Sort Result
sort=createdAt .sort('createdAt') Oldest first (ascending)
sort=-createdAt .sort('-createdAt') Newest first (descending)
sort=priority,-title .sort('priority -title') Priority asc, then title desc
sort=dueDate .sort({ dueDate: 1 }) Soonest due date first
Note: MongoDB’s skip() becomes increasingly slow as the offset grows โ€” skipping 10,000 documents requires scanning 10,000 documents before returning results. For endpoints with very large datasets or deep pagination (pages beyond 100), consider switching to cursor-based pagination using { _id: { $gt: lastSeenId } }. For typical task manager or admin UI use cases with at most a few thousand records, offset pagination is perfectly adequate.
Tip: Always whitelist sort fields to prevent clients from sorting by internal fields like __v, password, or other sensitive or non-indexed fields. Sorting by an unindexed field on a large collection triggers a full collection scan. A simple Set of allowed sort fields and a check before passing to Mongoose prevents both security issues and performance problems.
Warning: MongoDB’s $regex operator without an index is extremely slow โ€” it scans every document in the collection. For production full-text search, either create a MongoDB text index ({ title: 'text', description: 'text' }) and use the $text operator, or use Atlas Search for more advanced full-text capabilities. The $regex approach in this lesson is suitable for small collections but should not be used against large unindexed fields in production.

Complete Query Builder

// utils/QueryBuilder.js
class QueryBuilder {
    #model;
    #query;
    #countQuery;
    #page;
    #limit;

    // Allowed sort fields whitelist โ€” prevents sorting on sensitive/unindexed fields
    static ALLOWED_SORT_FIELDS = new Set([
        'createdAt', 'updatedAt', 'title', 'priority', 'status', 'dueDate',
    ]);

    constructor(model, queryParams) {
        const {
            page  = 1,
            limit = 10,
            sort  = '-createdAt',
            q,
            ...filters
        } = queryParams;

        this.#page  = Math.max(1, parseInt(page,  10));
        this.#limit = Math.min(100, parseInt(limit, 10));
        this.#model = model;

        // Build base filter
        const filter = this.#buildFilter(filters, q);

        // Validate and clean sort string
        const safeSort = this.#buildSort(sort);

        this.#query      = model.find(filter).sort(safeSort);
        this.#countQuery = model.countDocuments(filter);
    }

    #buildFilter(filters, searchQuery) {
        const filter = {};

        // Enum filters โ€” copy only allowed values
        const ENUM_FIELDS = {
            status:   new Set(['pending', 'in-progress', 'completed']),
            priority: new Set(['low', 'medium', 'high']),
        };

        for (const [key, allowed] of Object.entries(ENUM_FIELDS)) {
            if (filters[key] && allowed.has(filters[key])) {
                filter[key] = filters[key];
            }
        }

        // Date range filters
        if (filters.fromDate || filters.toDate) {
            filter.createdAt = {};
            if (filters.fromDate) filter.createdAt.$gte = new Date(filters.fromDate);
            if (filters.toDate)   filter.createdAt.$lte = new Date(filters.toDate);
        }

        // Full-text search (requires text index on model)
        if (searchQuery && searchQuery.trim()) {
            filter.$text = { $search: searchQuery.trim() };
        }

        return filter;
    }

    #buildSort(sortParam) {
        if (!sortParam) return '-createdAt';
        return sortParam
            .split(',')
            .filter(field => {
                const clean = field.replace(/^-/, '');
                return QueryBuilder.ALLOWED_SORT_FIELDS.has(clean);
            })
            .join(' ') || '-createdAt';
    }

    select(fields) {
        this.#query = this.#query.select(fields);
        return this;
    }

    populate(path, select) {
        this.#query = this.#query.populate({ path, select });
        return this;
    }

    addFilter(filter) {
        this.#query      = this.#query.find(filter);
        this.#countQuery = this.#model.countDocuments({ ...this.#query.getFilter(), ...filter });
        return this;
    }

    async execute() {
        const skip = (this.#page - 1) * this.#limit;

        const [data, total] = await Promise.all([
            this.#query.skip(skip).limit(this.#limit).lean(),
            this.#countQuery,
        ]);

        return {
            data,
            meta: {
                total,
                page:        this.#page,
                limit:       this.#limit,
                totalPages:  Math.ceil(total / this.#limit),
                hasNextPage: this.#page < Math.ceil(total / this.#limit),
                hasPrevPage: this.#page > 1,
            },
        };
    }
}

module.exports = QueryBuilder;

Using QueryBuilder in Controllers

// controllers/task.controller.js
const Task         = require('../models/task.model');
const QueryBuilder = require('../utils/QueryBuilder');
const asyncHandler = require('../utils/asyncHandler');

// GET /api/v1/tasks?status=pending&priority=high&q=meeting&page=2&limit=10&sort=-createdAt
exports.getAll = asyncHandler(async (req, res) => {
    const builder = new QueryBuilder(Task, req.query)
        .addFilter({ user: req.user.id })   // scoped to current user
        .select('-__v');

    const { data, meta } = await builder.execute();

    res.set('X-Total-Count', meta.total);
    res.json({ success: true, data, meta });
});
// models/task.model.js โ€” add text index
const taskSchema = new mongoose.Schema({
    title:       { type: String, required: true },
    description: { type: String },
    priority:    { type: String, enum: ['low', 'medium', 'high'], default: 'medium' },
    status:      { type: String, enum: ['pending', 'in-progress', 'completed'], default: 'pending' },
    dueDate:     { type: Date },
    user:        { type: mongoose.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true });

// Create a compound text index on title and description
// Title gets higher weight (importance) than description
taskSchema.index(
    { title: 'text', description: 'text' },
    { weights: { title: 10, description: 3 }, name: 'task_text_index' }
);

// Also index commonly filtered and sorted fields
taskSchema.index({ user: 1, status: 1 });
taskSchema.index({ user: 1, priority: 1 });
taskSchema.index({ user: 1, createdAt: -1 });
taskSchema.index({ user: 1, dueDate: 1 });

module.exports = mongoose.model('Task', taskSchema);

// Using $text search in a query
const tasks = await Task.find({
    user:  userId,
    $text: { $search: 'meeting project deadline' },
}, {
    score: { $meta: 'textScore' }   // include relevance score
})
.sort({ score: { $meta: 'textScore' }, createdAt: -1 })  // sort by relevance
.lean();

How It Works

Step 1 โ€” Page Number and Limit Calculate the Skip Value

Offset pagination works by calculating how many documents to skip: skip = (page - 1) * limit. Page 1 skips 0 documents, page 2 skips limit documents, page 3 skips 2 * limit, and so on. MongoDB’s .skip(n).limit(limit) retrieves the correct window of documents. Always run a countDocuments(filter) in parallel to get the total for pagination meta โ€” using the same filter as the main query.

Step 2 โ€” Filters Build a MongoDB Query Object Incrementally

Each query parameter adds a condition to the MongoDB filter object. status=pending adds { status: 'pending' }. priority=high adds { priority: 'high' }. fromDate=2025-01-01 adds { createdAt: { $gte: new Date('2025-01-01') } }. The final filter object is passed to Task.find(filter). Whitelisting enum values (checking against a Set) prevents clients from injecting arbitrary MongoDB operators via query strings.

Step 3 โ€” Sort String Uses a Dash Prefix for Descending

Mongoose’s string sort format uses a dash prefix for descending order: '-createdAt' sorts newest first. 'priority -createdAt' sorts by priority ascending, then by date descending within each priority group. Validating each field against an allowed Set prevents sorting by sensitive or unindexed fields, protecting both security and performance.

A MongoDB text index converts the indexed fields’ text into tokens (words with stemming), stores them in an index, and enables the $text: { $search: 'query' } operator. MongoDB computes a relevance score for each matching document based on how often the search terms appear and in which fields (controlled by the weights option). You can sort by this score using { score: { $meta: 'textScore' } }.

Step 5 โ€” Parallel Queries Keep Response Times Low

Running the data query and the count query in parallel with Promise.all() means both execute simultaneously against MongoDB. The response time is max(dataQueryTime, countQueryTime) rather than dataQueryTime + countQueryTime. For a list endpoint called frequently, this halves the database time spent per request.

Real-World Example: Query String Reference

GET /api/v1/tasks

Filter by status:         ?status=pending
Filter by priority:       ?priority=high
Filter by multiple:       ?status=pending&priority=high
Full-text search:         ?q=client+meeting
Date range:               ?fromDate=2025-01-01&toDate=2025-12-31

Pagination:               ?page=2&limit=20
Sort (newest first):      ?sort=-createdAt
Sort (alphabetical):      ?sort=title
Sort (multi-field):       ?sort=priority,-dueDate
Default (none supplied):  sort=-createdAt, page=1, limit=10

Combined:
?status=pending&priority=high&q=meeting&page=1&limit=5&sort=-dueDate

Response:
{
  "success": true,
  "data": [ ... 5 tasks ... ],
  "meta": {
    "total": 42,
    "page": 1,
    "limit": 5,
    "totalPages": 9,
    "hasNextPage": true,
    "hasPrevPage": false
  }
}

Common Mistakes

Mistake 1 โ€” Not running count and data queries in parallel

โŒ Wrong โ€” sequential queries double database round-trip time:

const total = await Task.countDocuments(filter);  // wait for count...
const tasks = await Task.find(filter).skip(skip).limit(limit);  // then wait for data

✅ Correct โ€” run both in parallel:

const [tasks, total] = await Promise.all([
    Task.find(filter).skip(skip).limit(limit),
    Task.countDocuments(filter),
]);

Mistake 2 โ€” Using $regex for search on large collections

โŒ Wrong โ€” full collection scan on every search request:

filter.title = { $regex: searchQuery, $options: 'i' };  // scans EVERY document

✅ Correct โ€” use a text index for full-text search:

filter.$text = { $search: searchQuery };  // uses text index โ€” fast
// Requires: taskSchema.index({ title: 'text', description: 'text' })

Mistake 3 โ€” Not capping the limit โ€” client requests limit=99999

โŒ Wrong โ€” client can request all documents at once:

const limit = parseInt(req.query.limit, 10) || 10;
// Client sends limit=50000 โ€” returns 50,000 documents!

✅ Correct โ€” always enforce a maximum:

const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 10));
// Maximum 100, minimum 1, default 10

Quick Reference

Feature Query String Mongoose Equivalent
Page number ?page=2 .skip((page-1)*limit)
Page size ?limit=20 .limit(20)
Sort ascending ?sort=title .sort('title')
Sort descending ?sort=-createdAt .sort('-createdAt')
Filter by field ?status=pending .find({ status: 'pending' })
Text search ?q=meeting .find({ $text: { $search: 'meeting' } })
Total count Response meta Model.countDocuments(filter)
Parallel queries โ€” await Promise.all([find, count])

🧠 Test Yourself

A client requests page 5 with a limit of 10. How many documents should MongoDB skip, and what is the Mongoose query?