Skip to content

Paginación en APIs ​

Dos estrategias listas para usar: offset (páginas numeradas, ideal para tablas de administración) y cursor (ideal para scroll infinito y tablas enormes). Incluye un helper que lee y valida los parámetros y arma los metadatos de la respuesta.

Helper reutilizable ​

js
/**
 * Lee ?pagina= y ?limite= de la query con valores seguros.
 * @returns {{ pagina: number, limite: number, offset: number }}
 */
export function leerPaginacion(query, { limitePorDefecto = 20, limiteMaximo = 100 } = {}) {
  const pagina = Math.max(1, Number.parseInt(query.pagina, 10) || 1);
  const limite = Math.min(limiteMaximo, Math.max(1, Number.parseInt(query.limite, 10) || limitePorDefecto));
  return { pagina, limite, offset: (pagina - 1) * limite };
}

/** Arma la respuesta estándar con metadatos y enlaces */
export function respuestaPaginada(req, data, total, { pagina, limite }) {
  const paginas = Math.max(1, Math.ceil(total / limite));
  const base = `${req.protocol}://${req.get('host')}${req.baseUrl}${req.path}`;
  const enlace = (p) => {
    const params = new URLSearchParams({ ...req.query, pagina: p, limite });
    return `${base}?${params}`;
  };
  return {
    data,
    meta: { total, pagina, limite, paginas, tieneSiguiente: pagina < paginas, tieneAnterior: pagina > 1 },
    links: {
      primera: enlace(1),
      anterior: pagina > 1 ? enlace(pagina - 1) : null,
      siguiente: pagina < paginas ? enlace(pagina + 1) : null,
      ultima: enlace(paginas),
    },
  };
}

/** Lista blanca de ordenamiento: ?orden=precio&dir=desc */
export function leerOrden(query, permitidos, porDefecto) {
  const campo = permitidos.includes(query.orden) ? query.orden : porDefecto;
  const dir = String(query.dir).toLowerCase() === 'asc' ? 'ASC' : 'DESC';
  return `${campo} ${dir}`;
}

Estrategia 1: offset (páginas numeradas) ​

js
import { query } from '../db/mysql.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { leerPaginacion, respuestaPaginada, leerOrden } from '../utils/paginacion.js';

export const listar = asyncHandler(async (req, res) => {
  const pag = leerPaginacion(req.query);
  const orden = leerOrden(req.query, ['id', 'nombre', 'precio', 'creado_en'], 'id');

  // Filtros opcionales
  const where = ['activo = 1'];
  const params = [];
  if (req.query.q) { where.push('nombre LIKE ?'); params.push(`%${req.query.q}%`); }
  if (req.query.categoria) { where.push('categoria_id = ?'); params.push(Number(req.query.categoria)); }
  const sqlWhere = 'WHERE ' + where.join(' AND ');

  const [[{ total }], filas] = await Promise.all([
    query(`SELECT COUNT(*) AS total FROM productos ${sqlWhere}`, params),
    query(`SELECT id, nombre, precio FROM productos ${sqlWhere}
           ORDER BY ${orden} LIMIT ${pag.limite} OFFSET ${pag.offset}`, params),
  ]);

  res.json(respuestaPaginada(req, filas, total, pag));
});

Respuesta de GET /api/productos?pagina=2&limite=10&q=café:

json
{
  "data": [{ "id": 31, "nombre": "Café de Veracruz", "precio": 165 }],
  "meta": { "total": 47, "pagina": 2, "limite": 10, "paginas": 5, "tieneSiguiente": true, "tieneAnterior": true },
  "links": {
    "primera": "http://localhost:3000/api/productos?q=caf%C3%A9&pagina=1&limite=10",
    "anterior": "http://localhost:3000/api/productos?q=caf%C3%A9&pagina=1&limite=10",
    "siguiente": "http://localhost:3000/api/productos?q=caf%C3%A9&pagina=3&limite=10",
    "ultima": "http://localhost:3000/api/productos?q=caf%C3%A9&pagina=5&limite=10"
  }
}

Estrategia 2: cursor (scroll infinito) ​

Con OFFSET 50000 la base de datos tiene que recorrer 50 000 filas para descartarlas. Con cursor, usa el índice y siempre es igual de rápido. Además, no se duplican elementos si alguien inserta registros mientras el usuario navega.

js
export const feed = asyncHandler(async (req, res) => {
  const limite = Math.min(50, Number(req.query.limite) || 20);
  const cursor = Number(req.query.cursor) || null; // id del último elemento recibido

  const filas = await query(
    `SELECT id, titulo, creado_en FROM publicaciones
     ${cursor ? 'WHERE id < ?' : ''}
     ORDER BY id DESC
     LIMIT ${limite + 1}`,             // pedimos 1 extra para saber si hay más
    cursor ? [cursor] : []
  );

  const hayMas = filas.length > limite;
  const data = hayMas ? filas.slice(0, limite) : filas;
  res.json({
    data,
    siguienteCursor: hayMas ? data.at(-1).id : null,
  });
});

Frontend con scroll infinito:

js
let cursor = null;
let cargando = false;

async function cargarMas() {
  if (cargando || cursor === undefined) return;
  cargando = true;
  const url = '/api/feed?limite=20' + (cursor ? `&cursor=${cursor}` : '');
  const { data, siguienteCursor } = await (await fetch(url)).json();
  data.forEach((p) => lista.insertAdjacentHTML('beforeend', `<li>${p.titulo}</li>`));
  cursor = siguienteCursor ?? undefined; // undefined = ya no hay más
  cargando = false;
}

// Carga más cuando el "centinela" al final de la lista entra en pantalla
new IntersectionObserver(([e]) => e.isIntersecting && cargarMas()).observe(document.querySelector('#centinela'));

¿Cuál usar? ​

OffsetCursor
Saltar a la página 7SíNo
Rendimiento con muchos datosEmpeoraConstante
Consistencia si cambian los datosPuede duplicar/omitirEstable
Ideal paraPaneles admin, tablasFeeds, scroll infinito, apps móviles

Índices

Ambas estrategias necesitan índices en las columnas de ORDER BY y WHERE. Por ejemplo: CREATE INDEX idx_productos_activo_id ON productos (activo, id);.

Error común

Confiar en ?limite= sin tope. Alguien puede pedir ?limite=1000000 y tumbar tu servidor. El helper siempre aplica un máximo.

Academia Web — aprende, copia y construye.