Heximon Logo
Recipes

Paginate a List

Validate a list route's filter/order/page query with QuerySchema.create, and shape its response with PaginatedResponseSchema — capped page size, coerced numeric operands, a total-count envelope.

A list endpoint needs three things a hand-rolled ?page=&size= parser tends to get wrong: a page size ceiling (so a client can't ask for 100,000 rows), coercion (a URL query is always strings, so gte=2000 has to become a number before it reaches your repository), and a consistent response envelope.

@heximon/schema ships both halves as one runtime builder — QuerySchema for the request, PaginatedResponseSchema for the response — over any Standard Schema.

Author the query schema

QuerySchema.create(shape, options) takes a field-shape (name → a leaf Standard Schema) and returns a class that is both a validated query type and the StandardSchemaV1 value a route's .query() references. Page mode (page/size) is the default; both fields are always present in the validated output, defaulted and capped — no handler-side fallback.

A URL query is string-valued, so a numeric filter operand needs a coercing leaf, or a comparison like priceCents: { gte: 2000 } arrives as the string "2000" and a bare z.number() rejects it:

src/products/product.query.ts
import { QuerySchema } from "@heximon/schema";
import { z } from "zod";

const numericOperand = z.coerce.number();

export class BrowseProductsQuery extends QuerySchema.create(
  { name: z.string(), priceCents: numericOperand },
  { maxPageSize: 100 },
) {}

z.coerce.number() both validates and coerces — the validator writes the coerced number back into the query, so a repository reading query.filter?.priceCents?.gte gets a real number, not the raw string.

Author the response schema

PaginatedResponseSchema.create(itemSchema) wraps a per-item schema in { data: item[], metadata: { queryCount } } — the total count alongside the current page, so a client can render "1,204 results" without a second request.

src/products/product.dto.ts
import { PaginatedResponseSchema, Schema, SchemaObject } from "@heximon/schema";
import { z } from "zod";

export class ProductResponse extends SchemaObject({
  id: z.string(),
  name: z.string(),
  priceCents: z.number(),
}) {}

export class ProductPageResponse extends Schema(
  PaginatedResponseSchema.create(ProductResponse.schema),
) {}

Schema(...) wraps the framework-emitted validator in a constructible class, so ProductPageResponse is usable as a route's responses entry (and emits a JSON Schema for OpenAPI) the same way any other DTO is.

Wire the route

Reference the query schema with .query() and the response with .responses() — same builder as any other route.

src/products/product.api.ts
import { Contract, Route } from "@heximon/contract";
import { BrowseProductsQuery, ProductPageResponse } from "./product.dto";

export class ProductsApi extends Contract({
  prefix: "/products",
  routes: {
    browse: Route.get("/").query(BrowseProductsQuery).responses({ 200: ProductPageResponse }),
  },
}) {}

Read the validated query in the handler

action.request.getValidatedQuery() returns the query already validated, coerced, and defaulted — the handler passes page/size straight to a repository's paging method and returns the shape PaginatedResponseSchema describes.

src/products/products.controller.ts
import type { Action, Controller } from "@heximon/http";
import { ProductsApi } from "./product.api";
import { ProductsRepository } from "./products.repository";

export class ProductsController implements Controller<ProductsApi> {
  public constructor(private readonly products: ProductsRepository) {}

  public async browse(action: Action<ProductsApi, "browse">) {
    const query = await action.request.getValidatedQuery();
    const { rows, total } = await this.products.getPage({ page: query.page, size: query.size }, query.filter);

    return { data: rows, metadata: { queryCount: total } };
  }
}
terminal
curl "http://localhost:3000/products?filter[priceCents][gte]=2000&page=2&size=10"
# → { "data": [...10 products...], "metadata": { "queryCount": 147 } }

curl "http://localhost:3000/products?size=99999"
# → 400 application/problem+json — "size must not exceed 100"
Offset mode. Pass { pagination: "offset" } instead and the validated query carries limit/offset rather than page/size — same ceiling, defaulting, and coercion, for a repository that pages by offset.

See also

  • ValidationSchemaObject/SchemaArray, and bringing your own validator.
  • Contracts — the full Route builder, and binding a contract-mode controller.
  • Repository pattern — filtering/paging an EntityQuery from a validated query, and mapping response-vocabulary field names to entity fields with a QueryMapper when they differ.
Copyright © 2026