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 with Middleware
const express = require('express')
const paginate = require('./middleware/paginate')
const app = express()
app.get('/api/posts', paginate(20, 100), async (req, res) => {
const { limit, offset } = req.pagination
try {
const posts = await db.posts.findAll({ limit, offset })
const total = await db.posts.count()
res.json(res.paginate(posts, total))
} catch (error) {
res.status(500).json({ error: error.message })
}
})
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 }
if (cursor) {
query.where = {
id: { $lt: cursor }
}
}
query.order = [['id', 'DESC']]
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 })
}
})
Sequelize Pagination
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 })
}
})
Prisma Pagination
const { PrismaClient } = require('@prisma/client')
const prisma = new PrismaClient()
app.get('/api/posts', async (req, res) => {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
try {
const [posts, total] = await Promise.all([
prisma.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' }
}),
prisma.post.count()
])
res.json({
data: posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / 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 })
}
})
Link Header Pagination
function buildLinkHeader(baseUrl, page, limit, total) {
const totalPages = Math.ceil(total / limit)
const links = []
if (page > 1) {
links.push(`<${baseUrl}?page=1&limit=${limit}>; rel="first"`)
links.push(`<${baseUrl}?page=${page - 1}&limit=${limit}>; rel="prev"`)
}
if (page < totalPages) {
links.push(`<${baseUrl}?page=${page + 1}&limit=${limit}>; rel="next"`)
links.push(`<${baseUrl}?page=${totalPages}&limit=${limit}>; rel="last"`)
}
return links.join(', ')
}
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
const users = await db.users.findAll({
limit,
offset: (page - 1) * limit
})
const total = await db.users.count()
res.set('Link', buildLinkHeader('/api/users', page, limit, total))
res.set('X-Total-Count', total)
res.json(users)
})
Keyset Pagination (Performance Optimized)
app.get('/api/articles', async (req, res) => {
const limit = parseInt(req.query.limit) || 20
const lastId = req.query.lastId
const lastCreatedAt = req.query.lastCreatedAt
try {
let where = {}
if (lastId && lastCreatedAt) {
where = {
[Op.or]: [
{ createdAt: { [Op.lt]: lastCreatedAt } },
{
[Op.and]: [
{ createdAt: lastCreatedAt },
{ id: { [Op.lt]: lastId } }
]
}
]
}
}
const articles = await Article.findAll({
where,
limit: limit + 1,
order: [
['createdAt', 'DESC'],
['id', 'DESC']
]
})
const hasNext = articles.length > limit
if (hasNext) {
articles.pop()
}
const lastArticle = articles[articles.length - 1]
res.json({
data: articles,
pagination: {
hasNext,
limit,
nextCursor: hasNext
? {
lastId: lastArticle.id,
lastCreatedAt: lastArticle.createdAt
}
: null
}
})
} catch (error) {
res.status(500).json({ error: error.message })
}
})
Pagination Helper Class
class Paginator {
constructor(model, query = {}) {
this.model = model
this.query = query
}
async paginate(page = 1, limit = 10) {
const offset = (page - 1) * limit
const [data, total] = await Promise.all([
this.model.findAll({
...this.query,
limit,
offset
}),
this.model.count({ where: this.query.where })
])
return {
data,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page < Math.ceil(total / limit),
hasPrev: page > 1
}
}
}
}
// Usage
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1
const limit = parseInt(req.query.limit) || 10
const paginator = new Paginator(User, {
where: { active: true },
order: [['createdAt', 'DESC']]
})
const result = await paginator.paginate(page, limit)
res.json(result)
})
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 implement filtering in Node.js and how to implement sorting in Node.js.



