Creating Reusable Pagination Middleware in Express

Building Flexible and Maintainable Pagination Solutions

Understanding Middleware in Express

Before we dive into creating pagination middleware, let's understand what middleware is and why it's so powerful. Think of middleware as a series of checkpoints that a request goes through before reaching its final destination. Just as a library might have a check-in desk where visitors show their cards before accessing different sections, middleware processes requests before they reach their final route handlers.

In our school supply management system, we've been implementing pagination in multiple routes. Instead of repeating this logic everywhere, we can create a single middleware that handles pagination consistently across our entire application. This is similar to how a school might have a standard procedure for processing new student registrations, rather than having each department handle it differently.

Creating the Pagination Middleware

Let's create a middleware that will process pagination parameters and add standardized pagination information to our requests. We'll build this step by step, understanding each component's purpose.

Basic Pagination Middleware Structure

// server/utils/pagination.js

/**
 * Middleware to handle pagination parameters and calculations
 * This middleware processes page and size query parameters,
 * validates them, and adds pagination details to the request object
 */
const paginationMiddleware = (options = {}) => {
    const {
        maxSize = 200,            // Maximum allowed page size
        defaultSize = 10,         // Default page size if none specified
        defaultPage = 1,          // Default page number if none specified
        allowUnlimited = true     // Whether to allow unlimited results (page=0,size=0)
    } = options;

    return (req, res, next) => {
        try {
            // Extract pagination parameters from query
            let { page, size } = req.query;
            
            // Convert to numbers and handle defaults
            page = page ? Number(page) : defaultPage;
            size = size ? Number(size) : defaultSize;

            // Handle the special case for unlimited results
            if (allowUnlimited && page === 0 && size === 0) {
                req.pagination = {
                    limit: null,
                    offset: 0,
                    page: 1,
                    size: null,
                    unlimited: true
                };
                return next();
            }

            // Validate pagination parameters
            if (page < 1 || size < 1 || size > maxSize) {
                return res.status(400).json({
                    errors: [{ 
                        message: 'Invalid pagination parameters. Page must be >= 1 and size must be between 1 and ' + maxSize 
                    }],
                    count: 0,
                    pageCount: 0
                });
            }

            // Calculate offset and limit for database queries
            const limit = size;
            const offset = (page - 1) * size;

            // Add pagination information to the request object
            req.pagination = {
                limit,
                offset,
                page,
                size,
                unlimited: false
            };

            next();
        } catch (error) {
            console.error('Error in pagination middleware:', error);
            res.status(500).json({
                errors: [{ message: 'Internal server error processing pagination' }],
                count: 0,
                pageCount: 0
            });
        }
    };
};

Our middleware does several important things:

1. It accepts configuration options to make it flexible for different use cases

2. It validates and processes pagination parameters

3. It handles special cases like unlimited results

4. It adds standardized pagination information to the request object

Enhancing the Middleware with Additional Features

Now that we have our basic middleware, let's add some helpful utilities that make it even more powerful and easier to use in our routes.

Adding Pagination Utilities

// server/utils/pagination.js

/**
 * Utility function to calculate total pages based on total count and page size
 * This helps routes provide consistent pagination metadata in responses
 */
const calculatePageCount = (totalCount, pageSize) => {
    if (!pageSize) return 1;
    return Math.ceil(totalCount / pageSize);
};

/**
 * Helper function to create a standardized paginated response
 * This ensures consistent response structure across all paginated routes
 */
const createPaginatedResponse = (data, totalCount, pagination) => {
    const { page, size, unlimited } = pagination;
    
    return {
        rows: data,
        count: totalCount,
        page: unlimited ? 1 : page,
        pageSize: size,
        pageCount: unlimited ? 1 : calculatePageCount(totalCount, size)
    };
};

/**
 * Enhanced pagination middleware with utility functions
 */
const createPaginationMiddleware = (options = {}) => {
    const middleware = paginationMiddleware(options);
    
    // Attach utility functions to the middleware
    middleware.createResponse = createPaginatedResponse;
    middleware.calculatePageCount = calculatePageCount;
    
    return middleware;
};

Using the Pagination Middleware

Let's see how our middleware simplifies route handlers and ensures consistent pagination across our application.

Implementing Paginated Routes

// Example route implementation
const express = require('express');
const router = express.Router();
const pagination = createPaginationMiddleware({
    maxSize: 50,
    defaultSize: 10
});

// Apply pagination middleware to a route
router.get('/students', pagination, async (req, res) => {
    try {
        const { limit, offset } = req.pagination;
        
        // Get total count and data in parallel
        const [totalCount, students] = await Promise.all([
            Student.count(),
            Student.findAll({
                limit,
                offset,
                order: [['lastName', 'ASC']]
            })
        ]);

        // Use utility function to create consistent response
        const response = pagination.createResponse(
            students,
            totalCount,
            req.pagination
        );

        res.json(response);
    } catch (error) {
        console.error('Error fetching students:', error);
        res.status(500).json({
            error: 'Failed to fetch students'
        });
    }
});

Testing the Pagination Middleware

To ensure our middleware works correctly in all scenarios, we should test it thoroughly. Here's how we can create comprehensive tests:

Middleware Tests

// server/utils/__tests__/pagination.test.js

describe('Pagination Middleware', () => {
    let req, res, next;

    beforeEach(() => {
        req = { query: {} };
        res = {
            status: jest.fn().mockReturnThis(),
            json: jest.fn()
        };
        next = jest.fn();
    });

    test('uses default values when no query parameters provided', () => {
        const middleware = paginationMiddleware();
        middleware(req, res, next);

        expect(req.pagination).toEqual({
            limit: 10,
            offset: 0,
            page: 1,
            size: 10,
            unlimited: false
        });
        expect(next).toHaveBeenCalled();
    });

    test('handles unlimited case correctly', () => {
        req.query = { page: '0', size: '0' };
        const middleware = paginationMiddleware();
        middleware(req, res, next);

        expect(req.pagination).toEqual({
            limit: null,
            offset: 0,
            page: 1,
            size: null,
            unlimited: true
        });
        expect(next).toHaveBeenCalled();
    });

    test('validates maximum page size', () => {
        req.query = { page: '1', size: '201' };
        const middleware = paginationMiddleware({ maxSize: 200 });
        middleware(req, res, next);

        expect(res.status).toHaveBeenCalledWith(400);
        expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
            errors: expect.any(Array)
        }));
        expect(next).not.toHaveBeenCalled();
    });
});

Best Practices and Considerations

When implementing and using pagination middleware, keep these important considerations in mind:

Configuration Management

Consider storing pagination configuration in environment variables or a configuration file. This allows for easy adjustments without changing code:

// config/pagination.js
module.exports = {
    maxSize: process.env.MAX_PAGE_SIZE || 200,
    defaultSize: process.env.DEFAULT_PAGE_SIZE || 10,
    defaultPage: 1,
    allowUnlimited: process.env.NODE_ENV === 'development'
};

Error Handling

Ensure your middleware handles all possible error cases gracefully and provides helpful error messages to clients.

Performance Considerations

Remember that pagination middleware runs on every request to routes where it's applied. Keep the logic efficient and avoid unnecessary operations.

Extending the Middleware

Consider these possible extensions to make the middleware even more powerful:

Add support for cursor-based pagination alongside offset-based pagination

Implement response caching for frequently accessed pages

Add support for different response formats (JSON, XML, CSV)

Include metadata about previous and next pages in the response