15 Pagination Implementation
title: Pagination Implementation in Node.js REST APIs weight: 25 date: 2026-06-28 lastmod: 2026-06-28 description: Implement offset and cursor-based pagination in Node.js REST APIs with Express, including pagination metadata, Link headers, and cursor encoding for consistent iteration. tags: [api-development, nodejs]
Pagination in Node.js REST APIs splits result sets into pages using offset-based (page/per_page) or cursor-based (cursor/limit) approaches, with pagination metadata and Link headers for client navigation.
```mermaid
flowchart TD
A[Pagination] --> B[Offset-Based]
A --> C[Cursor-Based]
B --> D[?page=2&per_page=20]
B --> E[Simple, skip-based]
C --> F[?cursor=abc&limit=20]
C --> G[Stable, key-based]
D --> H[Total + Page Count]
C --> I[Next Cursor + Has More]
style A fill:#e1f5fe
style B fill:#fff9c4
style C fill:#c8e6c9
Offset pagination calculates skip = (page - 1) * limit. It is simple but has performance issues on large datasets (skip scans all previous documents). Cursor pagination uses a unique field to mark position. It is faster and stable when data changes.
Think of offset pagination like a book with page numbers. If pages are inserted or removed, your page reference changes. Cursor pagination is like a bookmark. You always continue from where you left off, regardless of what happens between pages.
Example: Offset Pagination Middleware
const paginate = (defaultLimit = 10, maxLimit = 100) => {
return (req, res, next) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(maxLimit, Math.max(1, parseInt(req.query.limit) || defaultLimit));
const skip = (page - 1) * limit;
req.pagination = { page, limit, skip };
next();
};
};
// Usage in controller
app.get('/api/products',
paginate(20, 50),
async (req, res, next) => {
try {
const { skip, limit, page } = req.pagination;
const [products, total] = await Promise.all([
Product.find().skip(skip).limit(limit),
Product.countDocuments()
]);
// Build pagination response
const totalPages = Math.ceil(total / limit);
res.json({
status: 'success',
data: products,
meta: {
total,
page,
limit,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
});
} catch (error) {
next(error);
}
}
);
Example: Cursor Pagination Implementation
app.get('/api/products', async (req, res, next) => {
try {
const limit = Math.min(100, parseInt(req.query.limit) || 20);
const cursor = req.query.cursor || null;
let query = {};
if (cursor) {
// Decode cursor (base64 encoded {id, createdAt})
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString());
query = {
$or: [
{ createdAt: { $lt: new Date(decoded.createdAt) } },
{ createdAt: decoded.createdAt, _id: { $gt: decoded.id } }
]
};
}
const products = await Product.find(query)
.sort({ createdAt: -1, _id: 1 })
.limit(limit + 1);
const hasMore = products.length > limit;
if (hasMore) products.pop();
let nextCursor = null;
if (hasMore) {
const last = products[products.length - 1];
const cursorData = { id: last._id, createdAt: last.createdAt.toISOString() };
nextCursor = Buffer.from(JSON.stringify(cursorData)).toString('base64');
}
res.json({
status: 'success',
data: products,
meta: {
limit,
hasMore,
nextCursor
}
});
} catch (error) {
next(error);
}
});
Example: Link Header Pagination
const setPaginationHeaders = (res, total, page, limit) => {
const totalPages = Math.ceil(total / limit);
const baseUrl = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;
const links = {};
links.first = `${baseUrl}?page=1&limit=${limit}`;
links.last = `${baseUrl}?page=${totalPages}&limit=${limit}`;
if (page > 1) links.prev = `${baseUrl}?page=${page - 1}&limit=${limit}`;
if (page < totalPages) links.next = `${baseUrl}?page=${page + 1}&limit=${limit}`;
const linkStrings = Object.entries(links)
.map(([rel, url]) => `<${url}>; rel="${rel}"`);
res.setHeader('Link', linkStrings.join(', '));
res.setHeader('X-Total-Count', total);
res.setHeader('X-Page', page);
res.setHeader('X-Per-Page', limit);
};
Common Mistakes
- Performance issues with large skip values — MongoDB skip + limit becomes slow for high page numbers. Use cursor-based pagination or range queries for large datasets.
- Inconsistent results with offset pagination — When items are added or deleted between requests, users may see duplicates or miss items. Cursor pagination avoids this.
- Not limiting maximum page size — Allowing unlimited per_page value defeats the purpose of pagination. Set a maximum (100 is common).
- Missing pagination metadata — Without total, page, and totalPages, clients cannot build navigation UI. Always include metadata.
- Cursor exposing internal IDs — Base64 encoding is not encryption. Do not include sensitive data in cursors. Use opaque tokens if security is a concern.
Practice Questions
- What is the performance problem with offset pagination for large datasets?
- How does cursor pagination maintain consistency when data changes?
- What metadata should a paginated response include?
- What is the Link header used for in pagination?
- Challenge: Implement both offset and cursor pagination for a blog API. Compare the performance with 10000 posts. Create a pagination utility that supports both modes with a single interface.
FAQ
Mini Project
Build a pagination module that supports both offset and cursor-based pagination. Include middleware for parsing pagination parameters, response formatting with metadata, Link header generation, and cursor encoding/decoding. Test with both small and large datasets.
What's Next
Now learn about file upload in Building REST APIs with Node.js.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro