Tema
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?
| Offset | Cursor | |
|---|---|---|
| Saltar a la página 7 | Sí | No |
| Rendimiento con muchos datos | Empeora | Constante |
| Consistencia si cambian los datos | Puede duplicar/omitir | Estable |
| Ideal para | Paneles admin, tablas | Feeds, 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.