CSE 341: Web Services

W05 Learning Activity: Pagination

Overview

Pagination limits how much data an API returns in one response. Without pagination, a list endpoint can become slow, expensive, and difficult for clients to use as the database grows.

In an ecommerce application, pagination might be useful for product catalogs, customer orders, customer accounts, inventory records, or administrative data.

For example, an endpoint like GET /api/products might work well when the database has 10 products. But if the database eventually has hundreds or thousands of products, returning every record every time becomes a problem. The response takes longer to generate, transfers more data over the network (uses more bandwidth), and forces the client to process data it may not need.

Discipleship: Receiving growth line upon line

Pagination makes a large set of information usable by receiving it in portions. In a deeper way, the Lord often teaches and changes us gradually. This can be especially meaningful when you feel behind, overwhelmed, or frustrated by slow progress. Growth in discipleship, learning, and healing often comes in faithful portions rather than all at once.

2 Nephi 28:30 teaches:

For behold, thus saith the Lord God: I will give unto the children of men line upon line, precept upon precept, here a little and there a little; and blessed are those who hearken unto my precepts, and lend an ear unto my counsel, for they shall learn wisdom; for unto him that receiveth I will give more; and from them that shall say, We have enough, from them shall be taken away even that which they have.

Elder David A. Bednar, a modern-day Apostle of Jesus Christ, taught in The Spirit of Revelation:

The gradual increase of light radiating from the rising sun is like receiving a message from God “line upon line, precept upon precept” (2 Nephi 28:30). Most frequently, revelation comes in small increments over time and is granted according to our desire, worthiness, and preparation.

Invitation: Choose one area where you feel overwhelmed. Turn to God in prayer and ask Him to help you with one small step today.

When to Use Pagination

Any endpoint that returns a list of records should be considered for pagination. Some lists may be small today, but grow over time.

Pagination is an API design decision

Pagination is not only a database technique. It is part of the API contract. Clients need to know which query parameters are supported, what defaults are used, what limits are allowed, and how to tell whether more results exist.

Common Query Parameters

The most common approach to limiting results is page-based pagination. The client asks for a page number and a maximum number of records per page.

GET /api/products?page=2&limit=10&sort=name&order=asc

This request asks for the second page of products, with 10 products per page, sorted alphabetically by product name.

Defaults and Limits

Your API should have default values so clients can use a list endpoint without specifying every parameter. Your API should also have maximum values so clients cannot accidentally request too much data.

Default page: 1
Default limit: 10
Maximum limit: 50
Default sort: createdAt or name, depending on the endpoint
Default order: asc or desc, depending on the endpoint

These values should be consistent across related endpoints unless there is a clear reason to use different defaults.

Set a maximum limit

Do not let clients request unlimited results. A maximum limit protects the API and database from accidental or abusive requests.

Validating Pagination Parameters

Query parameters arrive as strings, so your API needs to parse and validate them before using them in a database query. Invalid pagination values should receive a clear 400 response.

Invalid examples:
GET /api/products?page=abc
GET /api/products?page=0
GET /api/products?limit=-5
GET /api/products?limit=100000
GET /api/products?sort=password

A helpful error response might look like this:

{
  "errors": [
    {
      "field": "limit",
      "message": "limit must be a number between 1 and 50."
    }
  ]
}

Response Metadata

A paginated response should include both the data and enough metadata for the client to know what page it received.

{
  "data": [],
  "pagination": {
    "page": 2,
    "limit": 10,
    "totalItems": 48,
    "totalPages": 5,
    "hasNextPage": true,
    "hasPreviousPage": true
  }
}

The metadata helps the client build pagination controls, such as next and previous buttons. It also helps API testers know whether the endpoint is returning the expected slice of data.

Some APIs include links instead of boolean values:

{
  "links": {
    "self": "/api/products?page=2&limit=10",
    "next": "/api/products?page=3&limit=10",
    "previous": "/api/products?page=1&limit=10"
  }
}

Returning direct links is a design decision, and is not strictly necessary, but you should include enough information for a client to request the next page.

Products Model

Keep database work in the model. The controller will validate the request and pass trusted pagination options to this function. The model then uses skip() and limit() to request only the needed product records.

// src/models/products.js
import { Product } from './product.js';

export const getPaginatedProducts = async ({ filter = {}, page, limit, sort, order }) => {
  const skip = (page - 1) * limit;
  const sortOptions = { [sort]: order };

  const [products, totalItems] = await Promise.all([
    Product.find(filter).sort(sortOptions).skip(skip).limit(limit),
    Product.countDocuments(filter)
  ]);

  return { products, totalItems };
};

The model does not read req or send a response. It receives a filter and validated options, returns the products and matching count, and can later be reused by another controller or service.

Products Controller

The controller owns HTTP concerns: reading query parameters, validating them, choosing supported sort fields, calling the model, and sending the JSON response.

// src/controllers/products.js
import { getPaginatedProducts } from '../models/products.js';

const allowedSortFields = ['name', 'price', 'category', 'createdAt'];

const parsePositiveInteger = (value, defaultValue) => {
  if (value === undefined) {
    return defaultValue;
  }

  const parsed = Number(value);
  if (!Number.isInteger(parsed) || parsed < 1) {
    return null;
  }

  return parsed;
};

export const getProducts = async (req, res, next) => {
  try {
    const page = parsePositiveInteger(req.query.page, 1);
    const requestedLimit = parsePositiveInteger(req.query.limit, 10);

    if (!page || !requestedLimit || requestedLimit > 50) {
      return res.status(400).json({
        errors: [{ field: 'pagination', message: 'page and limit must be valid positive numbers. Maximum limit is 50.' }]
      });
    }

    const limit = requestedLimit;
    if (req.query.sort && !allowedSortFields.includes(req.query.sort)) {
      return res.status(400).json({
        errors: [{ field: 'sort', message: 'sort is not supported.' }]
      });
    }

    const sort = req.query.sort || 'name';
    const order = req.query.order === 'desc' ? -1 : 1;

    const { products, totalItems } = await getPaginatedProducts({
      filter: {},
      page,
      limit,
      sort,
      order
    });

    return res.status(200).json({
      data: products,
      pagination: {
        page,
        limit,
        totalItems,
        totalPages: Math.ceil(totalItems / limit),
        hasNextPage: page * limit < totalItems,
        hasPreviousPage: page > 1
      }
    });
  } catch (error) {
    next(error);
  }
};

Keeping validation in the controller means the model receives only supported values. The controller uses the count returned by the model to create pagination metadata that describes the same filtered result set as the data array.

Swagger Documentation

Paginated endpoints should document each query parameter. A client should not need to guess the default values or allowed values.

parameters:
  - name: page
    in: query
    schema:
      type: integer
      default: 1
      minimum: 1
  - name: limit
    in: query
    schema:
      type: integer
      default: 10
      minimum: 1
      maximum: 50
  - name: sort
    in: query
    schema:
      type: string
      enum: [name, price, category, createdAt]
  - name: order
    in: query
    schema:
      type: string
      enum: [asc, desc]

Testing Pagination

When you test pagination, check more than whether the endpoint returns 200. Useful pagination tests include:

Activity Instructions

Check your understanding

  1. Why should a list endpoint use pagination as its data grows?
    Click to expand the example answer

    Pagination limits the number of records returned in one response. This reduces unnecessary database work, bandwidth use, and client-side processing when a collection becomes large.

  2. What is the purpose of the page and limit query parameters?
    Click to expand the example answer

    page identifies which page of results the client wants. limit identifies the maximum number of records to return on that page.

  3. Why should an API enforce a maximum value for limit?
    Click to expand the example answer

    A maximum limit prevents accidental or abusive requests for very large result sets, which helps protect the API and database.

  4. What metadata should a paginated response include to help a client build Next and Previous controls?
    Click to expand the example answer

    The response should include information such as the current page, limit, totalItems, totalPages, hasNextPage, and hasPreviousPage.

  5. Why should an API use an allowlist for the sort parameter?
    Click to expand the example answer

    An allowlist limits sorting to supported, useful fields such as name, price, or category. It keeps the API contract clear and avoids unexpected query behavior.

Other Links: