Menu
CoddyTech

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 icon

Challenge

Easy

A 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".

  1. In routes/tasks.js, change what the list route returns: an object instead of an array. For GET /api/tasks?limit=2 on 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
    }
    items holds this page's tasks, page is the page number, total is how many tasks match the filters, and pages is how many pages there are.
  2. Read page and limit from the query string:
    • page is a whole number from 1. Without it, use 1.
    • limit is a whole number from 1 to 50. Without it, use 10.

    Use the default before you convert, because Number(undefined) is NaN. Any other value (page=0, page=abc, page=1.5, limit=0, limit=51) gets status 400, with message limit must be a whole number from 1 to 50 for a bad limit:

    { "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } }
  3. Paginate last: filter and sort first, then cut out the page. total counts the tasks left after the filters, so ?done=false&limit=1 gives "total": 2 and "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;
quiz iconTest yourself

This lesson includes a short quiz. Start the lesson to answer it and track your progress.

All lessons in REST APIs

Practice on your own: Express playground