Schemas
Describe your data once with field helpers and let StarkForge build the Mongoose model
A schema lists the fields that a collection stores. You write it as a plain object, and StarkForge turns it into a Mongoose model when the server starts. A field can also carry the Zod rule that checks request values for it, so one object says both how a value is stored and how it is validated.
A Small Schema
This articles collection stores a title, a slug, a status and private review notes. It uses each kind of field that a schema accepts:
// src/collections/articles.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
// A wrapped field: your own Mongoose definition and your own Zod rule in one object.
const slug = {
mongoose: { type: String, required: true, unique: true, lowercase: true, trim: true },
zod: z.string().trim().min(3).max(80).regex(/^[a-z0-9-]+$/, "Use lowercase letters, digits and dashes"),
};
export const articles = collection({
type: "crud",
route: "articles",
model: "Article",
schema: {
// A helper: ready-made storage and validation.
title: fields.requiredString,
slug,
// A plain Mongoose definition: storage only.
status: { type: String, enum: ["draft", "published"], default: "draft" },
// A secret: stored, but never returned and never sortable.
reviewNotes: { type: String, select: false },
},
validations: {
create: {
title: fields.requiredString,
slug,
status: z.enum(["draft", "published"]).optional(),
reviewNotes: z.string().optional(),
},
},
routes: defineRoutes([
{ method: "get", path: "/", handler: "readAll", auth: "public" },
{ method: "get", path: "/:id", handler: "read", auth: "public" },
{ method: "post", path: "/", handler: "create", auth: "authenticated", roles: ["editor", "admin"], validation: "create" },
]),
});title is a helper that comes with StarkForge. slug is a wrapped field that you write yourself, and the same object appears in schema and in validations. status and reviewNotes are plain Mongoose definitions.
A request signed in as an editor creates an article. The request has no status, so the Mongoose default fills it in, and reviewNotes is stored but does not come back:
POST /api/v1/articles
Content-Type: application/json
{
"title": "Hello StarkForge",
"slug": "hello-starkforge",
"reviewNotes": "Check the intro"
}HTTP/1.1 201 Created
Content-Type: application/json
{
"success": true,
"message": "Article created successfully.",
"data": {
"title": "Hello StarkForge",
"slug": "hello-starkforge",
"_id": "665f1c2e8b3a4d0012a10001",
"status": "draft",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
}Three Ways to Write a Field
| Form | Looks like | Stored | Checks request values |
|---|---|---|---|
| A helper | fields.requiredString | Yes | Yes, when you reuse it in validations |
| A wrapped field | { mongoose: { type: String }, zod: z.string() } | Yes | Yes, when you reuse it in validations |
| A plain Mongoose definition | { type: String, trim: true } | Yes | No |
A helper is a wrapped field that StarkForge wrote for you. StarkForge recognizes a wrapped field by its two keys, mongoose and zod, and it needs both. Anything else in schema is a plain Mongoose definition, so every Mongoose field option works there, such as required, default, enum, index, ref and lowercase. The type can be a Mongoose type such as String, or its name as a string, such as "ObjectId".
A wrapper that has only one of the two keys fails when the server builds the app:
ConfigurationError (CONFIGURATION_ERROR): Schema field 'sku' has only one of 'mongoose' and 'zod'.
hint: A wrapped field needs both: { mongoose: { type: String }, zod: z.string() }. For a plain Mongoose definition, write it without the wrapper.Where the Zod Rule Is Used
The zod half of a field does nothing by itself. StarkForge uses it in two places:
- When you put the field into a group of
validations, astitleandslugare above. - When the built-in authentication builds its sign-up and profile rules from your user schema.
A crud route never derives its rules from the schema. It checks the body with the group that its validation key names, and with no group it accepts no body fields. The schema decides what can be stored, and the validation group decides what a route accepts, so the two lists must agree:
| A field is | What happens |
|---|---|
| In the schema but not in the validation group of a route | A client can never set it through that route, because validation drops it before the handler runs |
| In the validation group but not in the schema | The check passes, and then the key is not saved, because Mongoose ignores keys that the schema does not list |
fields.requiredNumber and fields.requiredIndexNumber require a value of at least 0.1, so a price of 0 fails when you reuse them in validations. Write z.number().min(0) yourself when zero is a valid value, as the products collection does.
What StarkForge Adds
StarkForge builds every model with the Mongoose options timestamps and strict turned on. A collection cannot change them or pass other schema options such as virtuals, hooks or compound indexes: the schema is the list of fields and nothing else.
| What | Behavior |
|---|---|
createdAt and updatedAt | Added to every document and kept current, so you do not declare them |
| Strict mode | A key that is not in the schema is not saved |
| Indexes | Fields with index: true or unique: true get their indexes when the server connects, before it accepts requests |
| Secret fields | A field with select: false is skipped by queries, never appears in a response and cannot be used for sorting |
__v | The Mongoose version key is stored, but responses do not include it |
_id | The MongoDB id, 24 hexadecimal characters, returned as a string |
Unique Fields
A field with unique: true has a unique index. When a request would create a second document with the same value, the database refuses it, and StarkForge answers 409 with the code DUPLICATE_KEY. This request repeats the slug from the example above:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"success": false,
"message": "An item with this slug already exists.",
"code": "DUPLICATE_KEY",
"hint": "'slug' is declared unique in the schema.",
"details": {
"fields": [
"slug"
]
},
"requestId": "a2fcbda1-f1d5-4f1b-9b17-89b85374d2fe"
}The hint and details appear only when exposeErrorDetails is on, which is the default outside production. A field that is unique but optional needs sparse: true, as sku has in the products collection. Without it, MongoDB treats a missing value as a value of its own, so only one document could leave the field out.
References Between Models
A reference stores the _id of a document from another model. Give the field type: "ObjectId" and a ref with the model name of the other collection. fields.userRef is a ready-made reference to a model named User, so it works only when your user model has that name.
// src/collections/comments.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
export const comments = collection({
type: "crud",
route: "comments",
model: "Comment",
schema: {
body: fields.requiredString,
// The id of a user. The helper points at the model named "User".
author: fields.userRef,
// The id of an article. For any other model, write the reference yourself.
article: {
mongoose: { type: "ObjectId", ref: "Article", required: true },
zod: fields.requiredObjectId.zod,
},
},
validations: {
create: { body: fields.requiredString, article: fields.requiredObjectId },
},
routes: defineRoutes([
{ method: "get", path: "/", handler: "readAll", auth: "public" },
{ method: "post", path: "/", handler: "create", auth: "authenticated", validation: "create" },
]),
});Reading the referenced document inside a response is called populating. Relations shows how to do it and which fields come back.
The User Model
Authentication needs a schema too. With builtinConfig.auth, you pass the schema of your user model as mongooseConfig, and StarkForge builds the sign-up and profile routes from it. The schema must define the email and password fields. This is the error when email is missing:
ConfigurationError (CONFIGURATION_ERROR): Invalid Forge configuration:
- auth.mongooseConfig.schema must define the 'email' field.
hint: Fix the listed options in new Forge({...}).Sign-up and profile updates accept only the fields that have a Zod rule. Build the user schema from helpers and wrapped fields: a plain Mongoose field is stored, but a client cannot set it through those routes. The Authentication page shows a complete user model.
Field Helpers
fields offers helpers for the common cases. Each one is a wrapped field, so the same name works in schema and in validations. In the names, required and optional say whether a value must be present, Index adds an index and Unique adds a unique index.
| Family | Helpers | Notes |
|---|---|---|
| Strings | requiredString, optionalString, optionalEmptyString, requiredIndexString, optionalIndexString, optionalEmptyIndexString, requiredUniqueString | Values are trimmed. The optional ones are stored as an empty string when missing |
| Email and password | email, password | email is required, unique and lowercased. password is a secret of 8 to 128 characters |
| Numbers | requiredNumber, optionalNumber, requiredIndexNumber, optionalIndexNumber | Text such as "12" is converted to a number. The optional ones default to 0 |
| Booleans | boolean, booleanTrue, booleanFalse | The last two set a default |
| Dates | dateNow, indexDateNow, requiredDate, optionalDate | Date text is converted to a date. dateNow defaults to the current time |
| Arrays | stringArray, requiredStringArray, objectArray | Also accept a JSON array or comma-separated text, which suits form bodies |
| References | objectId, requiredObjectId, indexObjectId, requiredIndexObjectId, userRef, requiredUserRef, userRefArray | The rule requires 24 hexadecimal characters |
| Roles and sign-in | role, provider, otp, otpExpiry, otpCount, otpStatus, purposeOTP | Building blocks for a user model and for custom one-time-code collections |
| Files and encryption | requiredFileMetaData, optionalFileMetaData, optionalEmptyFileMetaData, encryptedString | See Uploads and Hidden and Encrypted Fields |
| Timestamps | timestamps | Declares createdAt and updatedAt. StarkForge adds both anyway |
Two more exports hold the halves separately. mongooseFields has only the Mongoose half of each helper, and zodFields has only the Zod half.
fields.role accepts only ten role names: guest, user, moderator, editor, author, support, manager, admin, super_admin and owner. New users get user. If your app needs other role names, write the role field yourself with your own enum.
Where to Go Next
Relations
Populate referenced documents and choose which fields come back.
Hidden and Encrypted Fields
Keep secrets out of responses and encrypt them at rest.
:::