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.
// 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:
GET /api/v1/products/665f1c2e8b3a4d0012a10001HTTP/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
- 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.
- Your
beforeactions run. Any of them can stop the request by throwing an error. - The id in the URL must be 24 hexadecimal characters. Anything else answers 400.
- The document is loaded, with the populate setting of the route applied. A well-formed id that matches no document answers 404.
- On an
admin-or-ownerroute, the handler checks that the caller owns the document or is an administrator. Otherwise it answers 403. - Fields listed in
decryptedFieldsare decrypted, and private fields are removed. - Your
afteractions run, and the handler answers200.
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:
auth | Who can read | Which documents |
|---|---|---|
"public" | Anyone, signed in or not | Every document |
"authenticated" | Any signed-in user, limited by roles when the route lists them | Every document |
"admin-or-owner" | Administrators and the owner of the document | Only the documents the caller owns, unless the caller is an administrator |
"admin" | Administrators | Every document |
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:
// 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:
GET /api/v1/notes/665f1c2e8b3a4d0012a10008HTTP/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.
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:
GET /api/v1/notes/665f1c2e8b3a4d0012a10008HTTP/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
| Status | code | When it happens | What to change |
|---|---|---|---|
| 400 | INVALID_OBJECT_ID | The 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 |
| 401 | ACCESS_TOKEN_MISSING | The route needs sign-in and no token or cookie was sent | Sign in first |
| 403 | ROLE_NOT_ALLOWED | The route lists roles and the caller's role is not among them | Sign in with an allowed role, or add the role to roles |
| 403 | OWNER_REQUIRED | An admin-or-owner route and a document that belongs to someone else | Read your own documents, or sign in as an administrator |
| 404 | RESOURCE_NOT_FOUND | The 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.
:::