Pagination
Part of the REST APIs section of Coddy's Node.js journey. Lesson 17 of 25.
A list of ten thousand tasks is too long for one answer. Send it in pages: the client asks for ?page=2&limit=20 and gets that slice, plus what it needs to draw "Page 2 of 5":
{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }const start = (page - 1) * limit; // page 1 starts at 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);Count total after the filters, since it describes the list the client asked for. When the query string has no page, use 1, and 10 for limit, before you convert: Number(undefined) is NaN. Then refuse anything that is not a whole number in range with 400. A maximum for limit stops a client from asking for everything at once.
Challenge
EasyA list of ten thousand tasks is too long for one response. Send the list in pages, with what a client needs to show "Page 1 of 2".
- In
routes/tasks.js, change what the list route returns: an object instead of an array. ForGET /api/tasks?limit=2on the starting tasks, it returns:{ "items": [ { "id": 1, "title": "Write the project brief", "done": true, "createdAt": "2026-03-02T09:00:00.000Z" }, { "id": 2, "title": "Book the meeting room", "done": false, "createdAt": "2026-03-03T10:30:00.000Z" } ], "page": 1, "total": 3, "pages": 2 }itemsholds this page's tasks,pageis the page number,totalis how many tasks match the filters, andpagesis how many pages there are. - Read
pageandlimitfrom the query string:pageis a whole number from 1. Without it, use 1.limitis a whole number from 1 to 50. Without it, use 10.
Use the default before you convert, because
Number(undefined)isNaN. Any other value (page=0,page=abc,page=1.5,limit=0,limit=51) gets status 400, with messagelimit must be a whole number from 1 to 50for a bad limit:{ "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } } - Paginate last: filter and sort first, then cut out the page.
totalcounts the tasks left after the filters, so?done=false&limit=1gives"total": 2and"pages": 2.
This changes the shape of the response, so a client that expected an array has to change too. The Versioning lesson shows how to handle that.
Try it: GET /api/tasks?limit=2 returns tasks 1 and 2 with "page": 1, "total": 3 and "pages": 2. GET /api/tasks?page=2&limit=2 returns only task 3. Plain GET /api/tasks returns all 3 tasks with "page": 1 and "pages": 1. GET /api/tasks?page=0 returns 400 with code invalid_query.
Try it yourself
import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';
const router = Router();
// A whole task, as PUT sends it. .strict() refuses fields it does not list.
const Task = z
.object({
title: z.string().trim().min(1).max(100),
done: z.boolean(),
})
.strict();
const NewTask = Task.partial({ done: true });
const TaskChanges = Task.partial();
const SORT_FIELDS = ['title', 'createdAt'];
// True when another task already has this title, in any case.
const titleTaken = (title, exceptId) =>
tasks.some((t) => t.id !== exceptId && t.title.toLowerCase() === title.toLowerCase());
router.get('/', (req, res) => {
const { done, q, sort } = req.query;
let list = tasks;
if (done !== undefined) {
if (done !== 'true' && done !== 'false') {
return sendError(res, 400, 'invalid_query', 'done must be true or false');
}
list = list.filter((t) => t.done === (done === 'true'));
}
if (q !== undefined) {
const words = String(q).toLowerCase();
list = list.filter((t) => t.title.toLowerCase().includes(words));
}
if (sort !== undefined) {
const field = String(sort).startsWith('-') ? String(sort).slice(1) : String(sort);
if (!SORT_FIELDS.includes(field)) {
return sendError(
res,
400,
'invalid_query',
'sort must be title or createdAt, with - in front to reverse',
);
}
const direction = String(sort).startsWith('-') ? -1 : 1;
list = list.toSorted((a, b) => a[field].localeCompare(b[field]) * direction);
}
res.json(list);
});
router.get('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
res.json(task);
});
router.post('/', (req, res) => {
const result = NewTask.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { title, done = false } = result.data;
if (titleTaken(title)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
const task = { id: nextId(), title, done, createdAt: new Date().toISOString() };
tasks.push(task);
res.status(201).location(`/api/tasks/${task.id}`).json(task);
});
router.patch('/:id', (req, res) => {
const task = tasks.find((t) => t.id === Number(req.params.id));
if (!task) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = TaskChanges.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
if (result.data.title !== undefined && titleTaken(result.data.title, task.id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
Object.assign(task, result.data);
res.json(task);
});
router.put('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
const result = Task.safeParse(req.body);
if (!result.success) {
return sendInvalid(res, result.error);
}
const { id, createdAt } = tasks[index];
if (titleTaken(result.data.title, id)) {
return sendError(res, 409, 'conflict', 'A task with this title already exists');
}
tasks[index] = { id, ...result.data, createdAt };
res.json(tasks[index]);
});
router.delete('/:id', (req, res) => {
const index = tasks.findIndex((t) => t.id === Number(req.params.id));
if (index === -1) {
return sendError(res, 404, 'not_found', 'Task not found');
}
tasks.splice(index, 1);
res.status(204).end();
});
export default router;
This lesson includes a short quiz. Start the lesson to answer it and track your progress.
All lessons in REST APIs
1Resources and Verbs
Resources and URLsCreating with POST4Browsers, Versions and Config
What Is an OriginAllowing Another OriginCORSA Page That Calls the APIPractice on your own: Express playground