javascript 93 lines · 3 tabs

Cursor-Based Pagination in Express With Query-Parsing Middleware

Shared by codesnips Jul 2026
3 tabs
const MAX_LIMIT = 100;
const DEFAULT_LIMIT = 20;
const ALLOWED_ORDER = new Set(['asc', 'desc']);

function decodeCursor(raw) {
  const json = Buffer.from(raw, 'base64').toString('utf8');
  const parsed = JSON.parse(json);
  if (!parsed.createdAt || !parsed.id) throw new Error('bad cursor');
  return { createdAt: parsed.createdAt, id: parsed.id };
}

function encodeCursor(row) {
  const payload = JSON.stringify({ createdAt: row.createdAt, id: row.id });
  return Buffer.from(payload, 'utf8').toString('base64');
}

function parsePagination(req, res, next) {
  const rawLimit = Number(req.query.limit);
  const limit = Number.isFinite(rawLimit)
    ? Math.min(Math.max(Math.trunc(rawLimit), 1), MAX_LIMIT)
    : DEFAULT_LIMIT;

  const order = ALLOWED_ORDER.has(req.query.order) ? req.query.order : 'desc';

  let cursor = null;
  if (req.query.cursor) {
    try {
      cursor = decodeCursor(String(req.query.cursor));
    } catch (err) {
      return res.status(400).json({ error: 'Invalid cursor parameter' });
    }
  }

  req.pagination = { limit, order, cursor };
  next();
}

module.exports = { parsePagination, encodeCursor, DEFAULT_LIMIT, MAX_LIMIT };
3 files · javascript Explain with highlit

This snippet shows how a paginated JSON list endpoint is structured in Express by separating concerns: a middleware normalizes and validates raw query strings, and a thin controller consumes the already-clean values to fetch data and build a stable response. The design uses cursor-based (keyset) pagination rather than LIMIT/OFFSET because offsets get slower as pages grow and can skip or repeat rows when records are inserted between requests. A cursor encodes the last-seen sort position, so each page is fetched with a range predicate that stays fast and consistent.

In parsePagination middleware, the goal is to turn messy, attacker-controlled input into a trustworthy req.pagination object. The limit is coerced with Number and clamped between 1 and MAX_LIMIT so a client cannot request a million rows. The opaque cursor is decoded from base64 JSON via decodeCursor; a malformed cursor throws, which is caught and surfaced as a 400 rather than leaking a stack trace. Sort direction is whitelisted against ALLOWED_ORDER — this matters because sort keys often flow into SQL, and echoing arbitrary strings into a query is an injection risk. Validated defaults mean the controller never re-checks anything.

The articlesController reads req.pagination and fetches limit + 1 rows: the extra row is a cheap way to know whether a next page exists without a second COUNT query. If the sentinel row comes back, it is popped off and used to build nextCursor via encodeCursor, capturing both createdAt and id so ties on the timestamp are broken deterministically. The hasMore boolean and nextCursor are returned under a pageInfo object, a shape that mirrors common GraphQL connection conventions and keeps the client logic simple: fetch, render data, and pass nextCursor back verbatim.

In articles routes, the middleware is mounted only on the list route, so the controller stays free of parsing logic and can be unit-tested by injecting a fake req.pagination. This separation is the real lesson: parsing/validation lives in one reusable place, the controller expresses intent, and pagination remains correct under concurrent writes. A pitfall to watch is that the cursor sort columns must match the query's ORDER BY exactly, otherwise ranges skip rows.


Related snips

Share this code

Here's the card — post it anywhere.

Cursor-Based Pagination in Express With Query-Parsing Middleware — share card
Link copied