6 min read
Adding Pagination to Your Node.js API
This tutorial builds on our tested API by implementing robust pagination for GET requests. We'll cover different pagination strategies, cursor-based pagination, and how to handle large datasets efficiently.
Prerequisites
- Completed Parts 1-6 of the tutorial
- Basic understanding of REST APIs
- MongoDB and Node.js installed
Project Setup
Install the required dependencies:
npm install mongoose-cursor-pagination query-string
Updated Project Structure
Add these new files to your project:
books-api/
├── src/
│ ├── config/
│ │ └── pagination.js
│ ├── middleware/
│ │ └── pagination.js
│ ├── utils/
│ │ └── paginationHelpers.js
│ └── types/
│ └── pagination.js
Pagination Configuration
Create src/config/pagination.js
:
module.exports = {
// Default pagination settings
DEFAULT_PAGE_SIZE: 10,
MAX_PAGE_SIZE: 100,
// Sorting defaults
DEFAULT_SORT_FIELD: 'createdAt',
DEFAULT_SORT_ORDER: 'desc',
// Cursor settings
CURSOR_ENCODING: 'base64',
// Response field names
META_FIELDS: {
totalCount: 'totalCount',
pageSize: 'pageSize',
currentPage: 'currentPage',
totalPages: 'totalPages',
hasNextPage: 'hasNextPage',
hasPrevPage: 'hasPrevPage',
nextCursor: 'nextCursor',
prevCursor: 'prevCursor'
}
};
Pagination Middleware
Create src/middleware/pagination.js
:
const {
DEFAULT_PAGE_SIZE,
MAX_PAGE_SIZE,
DEFAULT_SORT_FIELD,
DEFAULT_SORT_ORDER
} = require('../config/pagination');
// Offset-based pagination middleware
const offsetPagination = (req, res, next) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(
parseInt(req.query.limit) || DEFAULT_PAGE_SIZE,
MAX_PAGE_SIZE
);
const skip = (page - 1) * limit;
const sortField = req.query.sortBy || DEFAULT_SORT_FIELD;
const sortOrder = req.query.order || DEFAULT_SORT_ORDER;
req.pagination = {
page,
limit,
skip,
sort: { [sortField]: sortOrder === 'desc' ? -1 : 1 }
};
next();
};
// Cursor-based pagination middleware
const cursorPagination = (req, res, next) => {
const limit = Math.min(
parseInt(req.query.limit) || DEFAULT_PAGE_SIZE,
MAX_PAGE_SIZE
);
const cursor = req.query.cursor;
const sortField = req.query.sortBy || DEFAULT_SORT_FIELD;
const sortOrder = req.query.order || DEFAULT_SORT_ORDER;
req.pagination = {
limit,
cursor,
sort: { [sortField]: sortOrder === 'desc' ? -1 : 1 }
};
next();
};
module.exports = {
offsetPagination,
cursorPagination
};
Pagination Helpers
Create src/utils/paginationHelpers.js
:
const { META_FIELDS, CURSOR_ENCODING } = require('../config/pagination');
// Helper for offset-based pagination
const createOffsetPaginationResponse = async (
model,
query,
pagination,
projection = {},
populate = []
) => {
const { page, limit, skip, sort } = pagination;
// Execute queries in parallel
const [data, totalCount] = await Promise.all([
model
.find(query, projection)
.sort(sort)
.skip(skip)
.limit(limit)
.populate(populate),
model.countDocuments(query)
]);
const totalPages = Math.ceil(totalCount / limit);
return {
data,
meta: {
[META_FIELDS.totalCount]: totalCount,
[META_FIELDS.pageSize]: limit,
[META_FIELDS.currentPage]: page,
[META_FIELDS.totalPages]: totalPages,
[META_FIELDS.hasNextPage]: page < totalPages,
[META_FIELDS.hasPrevPage]: page > 1
}
};
};
// Helper for cursor-based pagination
const createCursorPaginationResponse = async (
model,
query,
pagination,
projection = {},
populate = []
) => {
const { limit, cursor, sort } = pagination;
const sortField = Object.keys(sort)[0];
const sortOrder = sort[sortField];
// Add cursor to query if provided
if (cursor) {
const decodedCursor = Buffer.from(cursor, CURSOR_ENCODING).toString('utf8');
const [cursorValue, cursorId] = decodedCursor.split('_');
const cursorQuery = {
$or: [
{
[sortField]: sortOrder === 1
? { $gt: cursorValue }
: { $lt: cursorValue }
},
{
[sortField]: cursorValue,
_id: sortOrder === 1
? { $gt: cursorId }
: { $lt: cursorId }
}
]
};
query = { $and: [query, cursorQuery] };
}
// Fetch one extra record to determine if there's a next page
const data = await model
.find(query, projection)
.sort(sort)
.limit(limit + 1)
.populate(populate);
const hasNextPage = data.length > limit;
if (hasNextPage) {
data.pop(); // Remove the extra record
}
// Create cursors
const nextCursor = hasNextPage
? Buffer.from(
`${data[data.length - 1][sortField]}_${data[data.length - 1]._id}`
).toString(CURSOR_ENCODING)
: null;
const prevCursor = cursor
? Buffer.from(
`${data[0][sortField]}_${data[0]._id}`
).toString(CURSOR_ENCODING)
: null;
return {
data,
meta: {
[META_FIELDS.pageSize]: limit,
[META_FIELDS.hasNextPage]: hasNextPage,
[META_FIELDS.hasPrevPage]: !!cursor,
[META_FIELDS.nextCursor]: nextCursor,
[META_FIELDS.prevCursor]: prevCursor
}
};
};
// Helper for generating pagination links
const generatePaginationLinks = (req, meta) => {
const { protocol, hostname, originalUrl } = req;
const baseUrl = `${protocol}://${hostname}`;
const queryParams = new URLSearchParams(req.query);
const links = {};
if (meta.hasNextPage) {
queryParams.set('page', meta.currentPage + 1);
links.next = `${baseUrl}${originalUrl.split('?')[0]}?${queryParams}`;
}
if (meta.hasPrevPage) {
queryParams.set('page', meta.currentPage - 1);
links.prev = `${baseUrl}${originalUrl.split('?')[0]}?${queryParams}`;
}
return links;
};
module.exports = {
createOffsetPaginationResponse,
createCursorPaginationResponse,
generatePaginationLinks
};
Updated Book Routes
Update src/routes/books.js
to implement pagination:
const express = require('express');
const router = express.Router();
const { Book } = require('../models/book');
const { offsetPagination, cursorPagination } = require('../middleware/pagination');
const {
createOffsetPaginationResponse,
createCursorPaginationResponse,
generatePaginationLinks
} = require('../utils/paginationHelpers');
// Get all books with offset-based pagination
router.get('/', offsetPagination, async (req, res) => {
try {
const query = {};
// Apply filters
if (req.query.genre) {
query.genre = req.query.genre;
}
if (req.query.author) {
query.author = new RegExp(req.query.author, 'i');
}
const result = await createOffsetPaginationResponse(
Book,
query,
req.pagination,
{}, // Projection
['author'] // Populate
);
// Generate HATEOAS links
const links = generatePaginationLinks(req, result.meta);
res.json({
...result,
links
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get all books with cursor-based pagination
router.get('/cursor', cursorPagination, async (req, res) => {
try {
const query = {};
// Apply filters
if (req.query.genre) {
query.genre = req.query.genre;
}
if (req.query.author) {
query.author = new RegExp(req.query.author, 'i');
}
const result = await createCursorPaginationResponse(
Book,
query,
req.pagination,
{}, // Projection
['author'] // Populate
);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
Response Headers
Add pagination metadata to response headers:
const addPaginationHeaders = (req, res, next) => {
const sendResponse = res.json;
res.json = function(body) {
if (body && body.meta) {
res.set({
'X-Total-Count': body.meta.totalCount,
'X-Page-Size': body.meta.pageSize,
'X-Current-Page': body.meta.currentPage,
'X-Total-Pages': body.meta.totalPages,
'X-Has-Next-Page': body.meta.hasNextPage,
'X-Has-Prev-Page': body.meta.hasPrevPage
});
}
return sendResponse.call(this, body);
};
next();
};
Pagination Tests
Create __tests__/integration/pagination.test.js
:
const request = require('supertest');
const app = require('../../src/server');
const { createTestBook } = require('../../src/utils/testUtils');
describe('Pagination Tests', () => {
// Create test data
beforeEach(async () => {
const books = Array(15).fill().map(() => createTestBook());
await Promise.all(books);
});
describe('Offset-based Pagination', () => {
it('should return paginated results with default settings', async () => {
const response = await request(app)
.get('/api/books')
.expect(200);
expect(response.body.data).toHaveLength(10); // Default page size
expect(response.body.meta.currentPage).toBe(1);
expect(response.body.meta.hasNextPage).toBe(true);
});
it('should respect custom page size', async () => {
const response = await request(app)
.get('/api/books?limit=5')
.expect(200);
expect(response.body.data).toHaveLength(5);
});
it('should enforce maximum page size', async () => {
const response = await request(app)
.get('/api/books?limit=200')
.expect(200);
expect(response.body.data.length).toBeLessThanOrEqual(100);
});
});
describe('Cursor-based Pagination', () => {
it('should return cursor-based results', async () => {
const response = await request(app)
.get('/api/books/cursor')
.expect(200);
expect(response.body.data).toBeDefined();
expect(response.body.meta.nextCursor).toBeDefined();
});
it('should handle cursor navigation', async () => {
// Get first page
const firstResponse = await request(app)
.get('/api/books/cursor?limit=5')
.expect(200);
// Get next page using cursor
const nextResponse = await request(app)
.get(`/api/books/cursor?cursor=${firstResponse.body.meta.nextCursor}`)
.expect(200);
expect(nextResponse.body.data).toHaveLength(5);
expect(nextResponse.body.meta.hasPrevPage).toBe(true);
});
});
});
Best Practices Implemented
Multiple Strategies
- Offset-based pagination
- Cursor-based pagination
- Flexible configuration
- HATEOAS compliance
Performance Optimization
- Parallel query execution
- Cursor-based efficiency
- Index utilization
- Resource limiting
Developer Experience
- Clear documentation
- Consistent responses
- Error handling
- Flexible configuration
Client Integration
- Response headers
- HATEOAS links
- Clear metadata
- Cursor encoding
Next Steps
To enhance your API further:
Conclusion
You now have a robust pagination system that:
- Handles large datasets efficiently
- Provides multiple pagination strategies
- Maintains consistent performance
- Follows REST best practices
- Supports various use cases
Remember to:
- Monitor pagination performance
- Index relevant fields
- Update documentation
- Test edge cases
- Consider caching strategies
The next tutorial will cover implementing proper logging for your API endpoints.
Related Posts
• 5 min read
APIs (Application Programming Interfaces) are the backbone of modern digital applications. They allow different software systems to communicate, exchange data, and collaborate seamlessly. As businesse...
• 4 min read
In today’s interconnected digital world, APIs (Application Programming Interfaces) are the backbone of communication between different software applications. From mobile apps to cloud services, APIs e...
• 5 min read
In the modern digital ecosystem, APIs (Application Programming Interfaces) serve as the backbone of connectivity. Whether you're building microservices, enabling integrations, or crafting data pipelin...