How to implement pagination in Node.js
Pagination divides large datasets into manageable pages, improving API performance and user experience. As the creator of CoreUI with 12 years of Node.js backend experience, I’ve implemented pagination strategies that handle billions of records efficiently while maintaining sub-100ms response times for enterprise applications.
The most scalable approach uses offset-based pagination for simple use cases and cursor-based pagination for real-time data streams.
Offset-Based Pagination
const express = require('express')
const app = express()
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
const offset = (page - 1) * limit
try {
const users = await db.users.findAll({
limit,
offset,
order: [['createdAt', 'DESC']]
})
const total = await db.users.count()
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1
}
})
} catch (error) {
res.status(500).json({ error: error.message })
}
})
Pagination Middleware
Create src/middleware/paginate.js:
function paginate(defaultLimit = 10, maxLimit = 100) {
return (req, res, next) => {
let page = parseInt(req.query.page) || 1
let limit = parseInt(req.query.limit) || defaultLimit
if (page < 1) page = 1
if (limit < 1) limit = defaultLimit
if (limit > maxLimit) limit = maxLimit
req.pagination = {
page,
limit,
offset: (page - 1) * limit
}
res.paginate = (data, total) => {
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1
}
}
}
next()
}
}
module.exports = paginate
Usage:
const paginate = require('./middleware/paginate')
app.get('/api/posts', paginate(20, 100), async (req, res) => {
const { limit, offset } = req.pagination
const posts = await db.posts.findAll({ limit, offset })
const total = await db.posts.count()
res.json(res.paginate(posts, total))
})
Cursor-Based Pagination
app.get('/api/messages', async (req, res) => {
const limit = parseInt(req.query.limit) || 20
const cursor = req.query.cursor
try {
const query = {
limit: limit + 1,
order: [['id', 'DESC']]
}
if (cursor) {
query.where = { id: { $lt: cursor } }
}
const messages = await db.messages.findAll(query)
const hasNext = messages.length > limit
if (hasNext) messages.pop()
const nextCursor = hasNext ? messages[messages.length - 1].id : null
res.json({
data: messages,
pagination: {
nextCursor,
hasNext,
limit
}
})
} catch (error) {
res.status(500).json({ error: error.message })
}
})
MongoDB Pagination
const MongoClient = require('mongodb').MongoClient
app.get('/api/products', async (req, res) => {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
const skip = (page - 1) * limit
try {
const db = req.app.locals.db
const collection = db.collection('products')
const [products, total] = await Promise.all([
collection.find({}).skip(skip).limit(limit).toArray(),
collection.countDocuments()
])
res.json({
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
})
} catch (error) {
res.status(500).json({ error: error.message })
}
})
Pagination with Sequelize
const { Op } = require('sequelize')
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
try {
const { count, rows } = await User.findAndCountAll({
limit,
offset: (page - 1) * limit,
order: [['createdAt', 'DESC']]
})
res.json({
data: rows,
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil(count / limit)
}
})
} catch (error) {
res.status(500).json({ error: error.message })
}
})
Pagination with Filters
app.get('/api/products', async (req, res) => {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
const { category, minPrice, maxPrice } = req.query
const where = {}
if (category) {
where.category = category
}
if (minPrice || maxPrice) {
where.price = {}
if (minPrice) where.price.$gte = parseFloat(minPrice)
if (maxPrice) where.price.$lte = parseFloat(maxPrice)
}
try {
const [products, total] = await Promise.all([
Product.findAll({
where,
limit,
offset: (page - 1) * limit
}),
Product.count({ where })
])
res.json({
data: products,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
},
filters: { category, minPrice, maxPrice }
})
} catch (error) {
res.status(500).json({ error: error.message })
}
})
Best Practice Note
This is the same pagination architecture we use in CoreUI enterprise applications. Offset-based pagination works well for small to medium datasets, while cursor-based pagination scales better for large datasets and real-time feeds. Always validate and sanitize page and limit parameters, implement maximum limit caps, and add indexes on columns used for pagination and sorting.
Related Articles
For related API patterns, check out how to handle filtering in Node.js APIs and how to handle sorting in Node.js APIs.



