S
Crud

Read

Fetch a single document by id with the read handler, and control who can see it and which fields come back

The read handler returns one document by its id. It checks who is calling, loads the document, removes the fields that must stay private and answers 200 OK. This page shows how to declare a read route, what a request and a response look like, how ownership limits who can see a document, and which errors to expect.

Declare a Read Route

A read route is a route whose handler is "read". Its path ends with the parameter :id, because the handler takes the id from the URL. A read route needs no validation, since 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: "/:id", handler: "read", auth: "public" }]),
});

With the collection route products, this makes GET /api/v1/products/:id. The auth value "public" lets anyone call it. Routes explains why the parameter must be named id.

Send a Request

Put the id of the document in the URL:

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

{
  "success": true,
  "message": "Product 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"
  }
}

The message is the model name followed by fetched successfully., and data is the document. Fields that must stay private are left out, as Responses lists.

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 id in the URL must be 24 hexadecimal characters. Anything else answers 400.
    4. The document is loaded, with the populate setting of the route applied. A well-formed id that matches no document answers 404.
    5. On an admin-or-owner route, the handler checks that the caller owns the document or is an administrator. Otherwise it answers 403.
    6. Fields listed in decryptedFields are decrypted, and private fields are removed.
    7. Your after actions run, and the handler answers 200.

Choose Who Can Read

The auth key on the route decides who gets past the first step and, for admin-or-owner, which documents they may read:

authWho can readWhich documents
"public"Anyone, signed in or notEvery document
"authenticated"Any signed-in user, limited by roles when the route lists themEvery document
"admin-or-owner"Administrators and the owner of the documentOnly the documents the caller owns, unless the caller is an administrator
"admin"AdministratorsEvery document
Public Means Everyone

A public route returns every field of the document except the private ones. Keep personal data out of such a collection, or mark it with select: false or hiddenFields, as Hidden and Encrypted Fields explains.

Owned Documents

A document belongs to the user whose id is stored in its owner field, which fields.userRef declares and the create handler fills in. A route with auth: "admin-or-owner" compares that field with the signed-in user. This collection lets each person read only their own notes, and administrators read all of them:

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: "/:id",
      handler: "read",
      auth: "admin-or-owner",
      decryptedFields: ["secret?"],
      config: { populate: "owner", populateSelect: "_id name" },
    },
  ]),
});

When a signed-in user asks for a note that belongs to someone else, the handler refuses:

Text
GET /api/v1/notes/665f1c2e8b3a4d0012a10008
Text
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "success": false,
  "message": "You can only access your own data.",
  "code": "OWNER_REQUIRED",
  "requestId": "c7313a27-1eb3-49c8-8b59-1b8cf68cb684"
}

The field must be named owner. For a collection whose documents are the users themselves, set ownership: "self" on the route, so that the document id is compared with the caller. Authorization covers ownership in full.

Missing or Not Yours

The ownership check runs after the document is loaded. A missing id answers 404, and a document that belongs to someone else answers 403, so a caller can tell that the id exists.

Decrypted and Populated Fields

Two route settings change what comes back. decryptedFields returns a field that is encrypted in the database as plain text, and config.populate replaces a stored id with the referenced document. The notes route above uses both. The owner of the note reads it like this:

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

{
  "success": true,
  "message": "Note fetched successfully.",
  "data": {
    "title": "Shopping list",
    "body": "Milk, eggs",
    "owner": {
      "name": "Rita Reader",
      "_id": "665f1c2e8b3a4d0012a10007"
    },
    "_id": "665f1c2e8b3a4d0012a10008",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:30:00.000Z",
    "secret": "my bank PIN is 1234"
  }
}

populateSelect lists the fields of the owner that may appear. It defaults to _id alone, so email addresses and other personal data of referenced documents never leak by accident. A route without decryptedFields never returns the secret, so list routes of the same collection leave it out. Relations and Hidden and Encrypted Fields describe both settings.

Errors

StatuscodeWhen it happensWhat to change
400INVALID_OBJECT_IDThe id in the URL is not 24 hexadecimal characters. The message is The id in the URL is not valid.Send the _id of a document
401ACCESS_TOKEN_MISSINGThe route needs sign-in and no token or cookie was sentSign in first
403ROLE_NOT_ALLOWEDThe route lists roles and the caller's role is not among themSign in with an allowed role, or add the role to roles
403OWNER_REQUIREDAn admin-or-owner route and a document that belongs to someone elseRead your own documents, or sign in as an administrator
404RESOURCE_NOT_FOUNDThe id is well formed but no document has it. The message is Product not found.Check the id

Where to Go Next

Relations

Populate referenced documents and choose which of their fields come back.

Hidden and Encrypted Fields

Keep secrets out of responses and encrypt them at rest.

:::