S
Crud

Read All

List documents with pagination, sorting and per-user filtering using readAll

The readAll handler returns a page of documents from a collection. A client chooses the page, the page size and the sort order with query parameters, and the response carries a meta object that tells it how many pages exist. On a collection whose documents belong to users, the handler returns only the documents of the caller. This page covers the route, the query parameters, who sees which documents and how a client walks through every page.

Declare a Read All Route

A read all route is a route whose handler is "readAll". It lists a collection, so its path is usually /, and it needs no validation, because a GET request has no body.

TypeScript
// src/collections/products.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";

export const products = collection({
  type: "crud",
  route: "products",
  model: "Product",
  schema: {
    name: fields.requiredString,
    price: fields.requiredNumber,
    sku: { mongoose: { type: String, unique: true, sparse: true }, zod: z.string().optional() },
  },
  routes: defineRoutes([{ method: "get", path: "/", handler: "readAll", auth: "public" }]),
});

With the collection route products, this makes GET /api/v1/products.

Send a Request

A request without query parameters returns the first page with the default size, newest document first:

Text
GET /api/v1/products
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product list fetched successfully.",
  "data": [
    {
      "name": "Desk lamp",
      "price": 34,
      "sku": "LP-003",
      "_id": "665f1c2e8b3a4d0012a10003",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    },
    {
      "name": "USB-C cable",
      "price": 9.5,
      "sku": "CB-002",
      "_id": "665f1c2e8b3a4d0012a10002",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    },
    {
      "name": "Mechanical keyboard",
      "price": 89.9,
      "sku": "KB-001",
      "_id": "665f1c2e8b3a4d0012a10001",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 50,
    "total": 3,
    "pages": 1
  }
}

The message is the model name followed by list fetched successfully., and data is always an array. A collection with no documents answers 200 with an empty array. Responses describes every field of meta.

What the Handler Does

The handler works through these steps in order, and stops at the first one that fails:

  1. 1
    1. The authenticator checks the access level and the roles. A caller who is not signed in gets 401, and one with the wrong role gets 403.
    2. Your before actions run. Any of them can stop the request by throwing an error.
    3. The query parameters are checked. A bad value answers 400.
    4. The database returns the requested page, and a second query counts every matching document. On an admin-or-owner route both queries cover only the documents of the caller, unless the caller is an administrator.
    5. Fields listed in decryptedFields are decrypted, and private fields are removed from every document.
    6. Your after actions run, and the handler answers 200 with data and meta.

Page Through the Results

Three query parameters control the page. All of them are optional:

ParameterDefaultRules
page1A whole number from 1 to 1,000,000, written with digits only
limit50A whole number from 1 to 1,000,000, written with digits only. A value above the maximum page size, which is 100 by default, is lowered to the maximum
sort-createdAt,-_idUp to five field names separated by commas. A leading - sorts in descending order

An empty value, as in ?page=, counts as missing and uses the default. The default and the maximum page size are the options defaultPageSize and maxPageSize of Configuration. The default cannot be larger than the maximum, or the app does not start. A request for a limit above the maximum is not an error: the response is a full page of the maximum size, and meta.limit shows the size in effect.

This request asks for two products per page, most expensive first:

Text
GET /api/v1/products?page=1&limit=2&sort=-price
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product list fetched successfully.",
  "data": [
    {
      "name": "Mechanical keyboard",
      "price": 89.9,
      "sku": "KB-001",
      "_id": "665f1c2e8b3a4d0012a10001",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    },
    {
      "name": "Desk lamp",
      "price": 34,
      "sku": "LP-003",
      "_id": "665f1c2e8b3a4d0012a10003",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 2,
    "total": 3,
    "pages": 2
  }
}

The same request with page=2 returns the rest of the list:

Text
GET /api/v1/products?page=2&limit=2&sort=-price
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product list fetched successfully.",
  "data": [
    {
      "name": "USB-C cable",
      "price": 9.5,
      "sku": "CB-002",
      "_id": "665f1c2e8b3a4d0012a10002",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    }
  ],
  "meta": {
    "page": 2,
    "limit": 2,
    "total": 3,
    "pages": 2
  }
}

A page beyond meta.pages is not an error either. It answers 200 with an empty data list, so a client stops when meta.page reaches meta.pages.

Sort the List

Sorting names the fields to sort by, separated by commas. The first field decides the order, and each following field breaks the ties of the one before it. Put - in front of a name to reverse it, as in ?sort=-price,name. Only the first five names count, and the rest are ignored.

A client can sort by _id, createdAt, updatedAt and the fields of the collection schema, with three exceptions. The order of a list reveals how the values compare, so a client may sort only by fields that it could read anyway:

FieldWhy a client cannot sort by it
A field with select: false in the schemaStarkForge never returns it
A field that any route of the collection hashes or encryptsThe database holds a hash or ciphertext
A field in config.hiddenFields of this routeThe route hides it from the response

Asking for any other field answers 400, and the hint names the fields that are allowed. This request, to the notes collection of the reference app, tries to sort by an encrypted field:

Text
GET /api/v1/notes?sort=secret
Text
HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "success": false,
  "message": "Cannot sort by 'secret'.",
  "code": "INVALID_QUERY",
  "hint": "Sortable fields: _id, createdAt, updatedAt, title, body, owner.",
  "requestId": "653bb04d-55cc-4218-bd7e-8c4ac13a0d04"
}

The hint is one of the error details. StarkForge includes it only when exposeErrorDetails is on, which is the default outside production, and Errors explains the option.

End With the Id

When many documents share the value that you sort by, the database can order them differently from one request to the next, and a document can then show up on two pages or on none. End the sort with _id, as in ?sort=-price,_id, to keep the order stable while a client pages through the list.

Invalid Queries

A page, limit or sort value that StarkForge cannot use answers 400 with the code INVALID_QUERY:

RequestMessage
?page=0 or ?page=abc'page' must be a positive integer.
?limit=2e3, ?limit=1.5 or ?limit=-1'limit' must be a positive integer.
?limit=1&limit=2, a parameter that appears twice'limit' must be a positive integer.
?sort=secret, a field that cannot be sortedCannot sort by 'secret'.
?sort=price&sort=name, a parameter that appears twice'sort' must be a string.

A query parameter with any other name is ignored.

Who Sees Which Documents

The auth value of the route decides which documents a list contains:

authThe list contains
"public"Every document
"authenticated"Every document, for any signed-in user. The route can list roles to narrow who may call it
"admin-or-owner"Only the documents that the caller owns. An administrator gets every document
"admin"Every document, for administrators only
Authenticated Is Not Private

A list with auth: "authenticated" returns the documents of every user to anyone who is signed in. Use admin-or-owner for data that belongs to one person.

On an admin-or-owner route, StarkForge adds the owner to the database query. It does not load other people's documents and remove them afterward. This collection lets each person list only their own notes:

TypeScript
// src/collections/notes.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";

export const notes = collection({
  type: "crud",
  route: "notes",
  model: "Note",
  schema: {
    title: fields.requiredString,
    body: fields.optionalString,
    secret: fields.encryptedString,
    owner: fields.userRef,
  },
  routes: defineRoutes([{ method: "get", path: "/", handler: "readAll", auth: "admin-or-owner" }]),
});

When Rita lists the notes, she gets her own note and nothing else:

Text
GET /api/v1/notes
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Note list fetched successfully.",
  "data": [
    {
      "title": "Shopping list",
      "body": "Milk, eggs",
      "owner": "665f1c2e8b3a4d0012a10007",
      "_id": "665f1c2e8b3a4d0012a10008",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "pages": 1
  }
}

The secret is left out because the route does not list it in decryptedFields. The owner is stored in a field named owner. For a collection whose documents are the users themselves, set ownership: "self" on the route, and each person lists only their own record. Authorization covers ownership in full.

Totals Stay Private

meta.total counts only the documents that the caller may see. A user cannot learn from it how many documents other people own.

Walk Through Every Page

A client reads meta.pages from the first response and then asks for the other pages. This module loads every product, 100 at a time:

TypeScript
// src/client/products.ts
export interface Product {
  _id: string;
  name: string;
  price: number;
  sku?: string;
  createdAt: string;
  updatedAt: string;
}

export interface PageMeta {
  page: number;
  limit: number;
  total: number;
  pages: number;
}

interface ListBody {
  success: true;
  message: string;
  data: Product[];
  meta: PageMeta;
}

const API = "https://api.acme.example/api/v1";

/** Fetches one page of products, the most expensive first. */
export async function listProducts(page: number, limit: number): Promise<ListBody> {
  const query = new URLSearchParams({ page: String(page), limit: String(limit), sort: "-price,_id" });
  const response = await fetch(`${API}/products?${query}`);
  if (!response.ok) {
    throw new Error(`The server answered ${response.status}.`);
  }
  return (await response.json()) as ListBody;
}

/** Walks through every page until it reaches meta.pages. */
export async function listAllProducts(): Promise<Product[]> {
  const products: Product[] = [];
  let page = 1;
  let pages = 1;
  while (page <= pages) {
    const body = await listProducts(page, 100);
    products.push(...body.data);
    pages = body.meta.pages;
    page += 1;
  }
  return products;
}

This list is public, so the request carries no credentials. For a route that needs sign-in, such as the notes list, add credentials: "include" to the fetch options so that the browser sends the session cookie.

What Is Not Built In

The readAll handler does one job, which is to page and sort a collection. It has no support for:

  • Filtering by the value of a field, such as ?category=books
  • Searching the text of documents
  • Choosing which fields come back
  • Cursor-based paging. A page number and a page size are the only way to move through a list

The page number becomes a database skip, so very deep pages cost more on a large collection. When you need a filter or a search, add an endpoint of your own, as Handlers describes. For a small collection, a client can also load every page and filter the documents itself.

Where to Go Next

Authorization

Choose who can call a route and which documents they own.

Hidden and Encrypted Fields

Keep secrets out of lists and encrypt them at rest.

:::