RecipesDatabase

Paginate a List Endpoint

Turn query-string page/limit params into a bounded slice of a list.

A list endpoint that returns every row does not scale, and a client should not have to guess your default page size. This recipe reads page/limit from ctx.query, validates them, and returns a bounded slice plus enough metadata for the client to fetch the next page.

Solution

src/routes/users.ts
import { createApp, listen, BadRequestError } from 'nextrush';

interface User {
  id: number;
  name: string;
}

// Replace with a real data source — this recipe is about the pagination
// contract, not storage.
const users: User[] = Array.from({ length: 247 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
}));

const DEFAULT_LIMIT = 20;
const MAX_LIMIT = 100;

function parsePagination(query: Record<string, string | string[] | undefined>) {
  const rawPage = Array.isArray(query.page) ? query.page[0] : query.page;
  const rawLimit = Array.isArray(query.limit) ? query.limit[0] : query.limit;

  const page = rawPage ? Number(rawPage) : 1;
  const limit = rawLimit ? Number(rawLimit) : DEFAULT_LIMIT;

  if (!Number.isInteger(page) || page < 1) {
    throw new BadRequestError('page must be a positive integer');
  }
  if (!Number.isInteger(limit) || limit < 1 || limit > MAX_LIMIT) {
    throw new BadRequestError(`limit must be between 1 and ${MAX_LIMIT}`);
  }

  return { page, limit };
}

const app = createApp();

app.get('/users', (ctx) => {
  const { page, limit } = parsePagination(ctx.query);
  const start = (page - 1) * limit;
  const items = users.slice(start, start + limit);
  const totalPages = Math.ceil(users.length / limit);

  ctx.json({
    items,
    page,
    limit,
    total: users.length,
    totalPages,
    hasNextPage: page < totalPages,
  });
});

listen(app, 8080);

ctx.query values are always string | string[] | undefined — a repeated query key (?page=1&page=2) parses as an array, which is why parsePagination normalizes before calling Number(). Rejecting non-integer or out-of-range values with BadRequestError keeps malformed input from becoming NaN and slicing the whole array. See Context for the full request/response API.

Cursor-based pagination

Offset pagination (page/limit) is simple but degrades on very large or frequently-changing datasets. For a cursor (?after=<id>) instead, sort by a stable, indexed column and filter WHERE id > :after LIMIT :limit at the data layer — the endpoint contract above stays the same, only parsePagination and the slice change.

Try It

curl "http://localhost:8080/users?page=2&limit=5"

Expected result:

{
  "items": [
    { "id": 6, "name": "User 6" },
    { "id": 7, "name": "User 7" },
    { "id": 8, "name": "User 8" },
    { "id": 9, "name": "User 9" },
    { "id": 10, "name": "User 10" }
  ],
  "page": 2,
  "limit": 5,
  "total": 247,
  "totalPages": 50,
  "hasNextPage": true
}
  • Contextctx.query typing and the full request/response API
  • Error HandlingBadRequestError and the rest of the HttpError hierarchy
Was this helpful?

On this page