8 Topics
Chapter•40 Minutes

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.

Offset-Based Pagination
Key Takeaways
  • 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.

Calculate Pagination
const page = 2;
const limit = 10;

const skip = (page - 1) * limit;

console.log(skip); // 10

Calculates the number of records to skip before retrieving the requested page.

Key Takeaways
  • 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.

Paginated User Service
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.

Key Takeaways
  • 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.

Paginated Users Controller
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.

Key Takeaways
  • Query parameters are accessed through req.query.

  • Set sensible defaults for pagination.

  • Enforce a maximum page size.

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.

Build User Filters
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.

Key Takeaways
  • 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.

Build Sorting Options
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.

Key Takeaways
  • 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:

  1. Parse query parameters.
  2. Validate the parameters.
  3. Build the MongoDB filter.
  4. Build the sorting options.
  5. Apply pagination.
  6. Retrieve matching records.
  7. Count matching records.
  8. Return pagination metadata.

The count query must use the same filter as the data query.

Use lean() for Read-Only Queries

Mongoose lean() can reduce memory overhead when you do not need document methods, getters, or change tracking.

Combined User Query Service
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.

Key Takeaways
  • 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.