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 };
const db = require('../db');
const { encodeCursor } = require('../middleware/parsePagination');
async function listArticles(req, res, next) {
const { limit, order, cursor } = req.pagination;
const dir = order === 'asc' ? 'ASC' : 'DESC';
const cmp = order === 'asc' ? '>' : '<';
const params = [];
let where = '';
if (cursor) {
params.push(cursor.createdAt, cursor.id);
where = `WHERE (created_at, id) ${cmp} ($1, $2)`;
}
params.push(limit + 1); // fetch one extra to detect a next page
const sql = `
SELECT id, title, created_at AS "createdAt"
FROM articles
${where}
ORDER BY created_at ${dir}, id ${dir}
LIMIT $${params.length}
`;
try {
const { rows } = await db.query(sql, params);
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
const nextCursor = hasMore ? encodeCursor(rows[rows.length - 1]) : null;
res.json({
data: rows,
pageInfo: { hasMore, nextCursor, limit, order },
});
} catch (err) {
next(err);
}
}
module.exports = { listArticles };
const express = require('express');
const { parsePagination } = require('../middleware/parsePagination');
const { listArticles } = require('../controllers/articlesController');
const router = express.Router();
router.get('/articles', parsePagination, listArticles);
router.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Internal Server Error' });
});
module.exports = router;
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
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import { randomBytes, createHash } from "crypto";
import jwt from "jsonwebtoken";
import { RefreshTokenStore } from "./store";
const ACCESS_SECRET = process.env.ACCESS_SECRET!;
const ACCESS_TTL = "15m";
JWT access + refresh token rotation (conceptual)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Form Validation Example</title>
<style>
HTML forms with validation and accessibility
Share this code
Here's the card — post it anywhere.