Pagination & Filtering
List endpoints need pagination that stays correct under concurrent writes and filtering that is documented, validated, and indexed. Node APIs typically expose cursor pagination for public lists and offset for small admin tools.
Recipe
Quick-reference recipe card - copy-paste ready.
GET /v1/orders?limit=20&cursor=eyJpZCI6Im9yZF8xMjMifQ&status=pending&customerId=cust_1{
"data": [ { "id": "ord_1", "status": "pending" } ],
"meta": {
"limit": 20,
"nextCursor": "eyJpZCI6Im9yZF8yIn0",
"hasMore": true
}
}RateLimit-Limit: 1000
RateLimit-Remaining: 998
RateLimit-Reset: 1720526400When to reach for this:
- Tables exceed a few thousand rows per tenant
- Clients page through feeds without duplicate/missing rows on updates
- Product needs filter combinations (
status, date range) - Public API documents list contracts in OpenAPI
Working Example
import express from "express";
import { z } from "zod";
const listQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
status: z.enum(["pending", "shipped"]).optional(),
customerId: z.string().uuid().optional(),
});
type Order = { id: string; customerId: string; status: string; createdAt: Date };
function encodeCursor(id: string): string {
return Buffer.from(JSON.stringify({ id }), "utf8").toString("base64url");
}
function decodeCursor(cursor: string): { id: string } {
return JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
}
async function listOrders(opts: {
limit: number;
cursor?: string;
status?: string;
customerId?: string;
}): Promise<{ rows: Order[]; nextCursor?: string }> {
// Simplified in-memory stand-in for SQL: WHERE (id > cursorId) ORDER BY id LIMIT n+1
const all: Order[] = [
{ id: "ord_1", customerId: "c1", status: "pending", createdAt: new Date() },
{ id: "ord_2", customerId: "c1", status: "shipped", createdAt: new Date() },
{ id: "ord_3", customerId: "c2", status: "pending", createdAt: new Date() },
];
let filtered = all;
if (opts.status) filtered = filtered.filter((o) => o.status === opts.status);
if (opts.customerId) filtered = filtered.filter((o) => o.customerId === opts.customerId);
if (opts.cursor) {
const { id } = decodeCursor(opts.cursor);
filtered = filtered.filter((o) => o.id > id);
}
filtered.sort((a, b) => a.id.localeCompare(b.id));
const slice = filtered.slice(0, opts.limit + 1);
const hasMore = slice.length > opts.limit;
const rows = hasMore ? slice.slice(0, opts.limit) : slice;
const nextCursor = hasMore ? encodeCursor(rows[rows.length - 1]!.id) : undefined;
return { rows, nextCursor };
}
const app = express();
app.get("/v1/orders", async (req, res) => {
const parsed = listQuery.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({ error: { code: "VALIDATION_ERROR" } });
}
const { rows, nextCursor } = await listOrders(parsed.data);
res.setHeader("RateLimit-Limit", "1000");
res.setHeader("RateLimit-Remaining", String(999));
res.json({
data: rows,
meta: { limit: parsed.data.limit, nextCursor, hasMore: Boolean(nextCursor) },
});
});What this demonstrates:
- Cursor is opaque base64url JSON (use signed cursors in production)
- Fetch
limit + 1to detecthasMorewithout separate count query - Filters validated with Zod from query string
- Rate limit headers on list responses
Deep Dive
How It Works
- Cursor pagination: stable sort key (usually
idor(createdAt, id)); client passes last seen cursor - Offset pagination:
?page=2&limit=20maps toOFFSET 20- simple but slow and inconsistent under inserts - Filtering: whitelist query params; reject unknown filters with 400
- Sorting: allow
sort=createdAt:descwith whitelist; index matching columns
Cursor vs Offset
| Aspect | Cursor | Offset |
|---|---|---|
| Consistency under writes | Strong | Duplicates/skips |
| Deep pages | Fast with index | Slow OFFSET 100000 |
| Jump to page N | Hard | Easy |
| Best for | Public APIs, feeds | Admin UIs, exports |
SQL Cursor Pattern
SELECT * FROM orders
WHERE customer_id = $1 AND status = $2
AND (created_at, id) > ($3, $4)
ORDER BY created_at ASC, id ASC
LIMIT $5;TypeScript Notes
// Signed cursor prevents tampering
import { createHmac, timingSafeEqual } from "node:crypto";
function signCursor(payload: string, secret: string) {
const sig = createHmac("sha256", secret).update(payload).digest("base64url");
return `${payload}.${sig}`;
}Gotchas
- Offset on large tables - Full table scan on page 5000. Fix: Cursor for public lists; export jobs async.
- Unindexed filters -
?search=textfull table scan. Fix: Index or dedicated search service. - Decimal IDs as cursor - Float comparison bugs. Fix: Use string ULID/UUID or tuple cursor.
- Returning total count always -
COUNT(*)expensive. Fix: OptionalincludeTotal=trueor omit. - No max
limit- Client requestslimit=100000. Fix: Cap at 100 in Zod schema.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Offset | Small datasets, jump to page | High-write feeds |
| Keyset (cursor) | Production list APIs | Need random page access |
| Relay GraphQL cursors | GraphQL clients | REST-only partners |
| Search after (Algolia) | Full-text search | Simple SQL filters enough |
FAQs
Should cursor be opaque?
Yes for public APIs - prevents clients coupling to internal sort keys. Document as opaque string.
How to paginate with createdAt duplicates?
Use compound cursor (createdAt, id) tuple encoded in JSON.
Filter with OR conditions?
Document supported combinations. Arbitrary OR trees are hard to index - restrict patterns.
Include RateLimit headers on all routes?
At minimum on list and search endpoints; ideally global middleware.
Backward pagination?
Rare in REST. Support direction=prev with reverse sort if product requires bi-directional scroll.
Empty page vs 404?
Return 200 with data: [] and no nextCursor - not 404.
Date range filters?
Use ISO8601 createdAfter / createdBefore query params; validate with Zod z.coerce.date().
NestJS pagination?
@Query() DTO + repository method returning cursor meta; same JSON envelope.
Export all rows?
Separate async export job with S3 link - not unbounded limit.
OpenAPI for list params?
Document every query param and meta schema in response.
Related
- API Design Basics - resource URLs
- OpenAPI & Swagger - document list params
- Error Response Standards - validation errors
- Performance - index tuning
- HTTP Basics in Node - headers
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.