Menu
CoddyTech

Paginación

Parte de la sección API REST del Journey de Node.js de Coddy. Lección 17 de 25.

Una lista de diez mil tareas es demasiado larga para una sola respuesta. Envíala por páginas: el cliente solicita ?page=2&limit=20 y obtiene ese fragmento, además de lo que necesita para mostrar «Página 2 de 5»:

{ "items": [ ...books 21 to 40... ], "page": 2, "total": 95, "pages": 5 }
const start = (page - 1) * limit;    // la página 1 comienza en 0
const items = list.slice(start, start + limit);
const pages = Math.ceil(list.length / limit);

Cuenta total después de aplicar los filtros, ya que describe la lista que solicitó el cliente. Si la cadena de consulta no incluye page, usa 1, y 10 para limit, antes de convertirlos: Number(undefined) es NaN. Después, rechaza con el código 400 cualquier valor que no sea un número entero dentro del rango. Un límite máximo para limit evita que un cliente solicite todo de una vez.

challenge icon

Desafío

Fácil

Una lista de diez mil tareas es demasiado larga para una sola respuesta. Envía la lista en páginas, con lo que un cliente necesita para mostrar «Página 1 de 2».

  1. En routes/tasks.js, cambia lo que devuelve la ruta de la lista: un objeto en lugar de un array. Para GET /api/tasks?limit=2 con las tareas iniciales, devuelve:
    {
      "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 contiene las tareas de esta página, page es el número de página, total es la cantidad de tareas que coinciden con los filtros y pages es la cantidad de páginas.
  2. Lee page y limit de la cadena de consulta:
    • page es un número entero a partir de 1. Si no se especifica, usa 1.
    • limit es un número entero del 1 al 50. Si no se especifica, usa 10.

    Usa el valor predeterminado antes de convertirlo, porque Number(undefined) es NaN. Cualquier otro valor (page=0, page=abc, page=1.5, limit=0, limit=51) devuelve el estado 400, con el mensaje limit must be a whole number from 1 to 50 cuando el límite no es válido:

    { "error": { "code": "invalid_query", "message": "page must be a whole number from 1" } }
  3. Página al final: primero filtra y ordena, luego extrae la página. total cuenta las tareas que quedan después de aplicar los filtros, así que ?done=false&limit=1 da "total": 2 y "pages": 2.

Esto cambia la estructura de la respuesta, así que también tiene que cambiar cualquier cliente que esperara un array. La lección sobre versionado muestra cómo hacerlo.

Pruébalo: GET /api/tasks?limit=2 devuelve las tareas 1 y 2 con "page": 1, "total": 3 y "pages": 2. GET /api/tasks?page=2&limit=2 devuelve solo la tarea 3. Un GET /api/tasks simple devuelve las 3 tareas con "page": 1 y "pages": 1. GET /api/tasks?page=0 devuelve 400 con el código invalid_query.

Pruébalo tú mismo

import { Router } from 'express';
import { z } from 'zod';
import { sendError, sendInvalid } from '../errors.js';
import { nextId, tasks } from '../store.js';

const router = Router();

// Una tarea completa, tal como la envía PUT. .strict() rechaza los campos que no figuran en su lista.
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 si otra tarea ya tiene este título, sin distinguir entre mayúsculas y minúsculas.
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 iconPonte a prueba

Esta lección incluye un breve cuestionario. Empieza la lección para responderlo y registrar tu progreso.

Todas las lecciones de API REST

Practica por tu cuenta: Playground de Express