Create
Add a document with the create handler, including validation, ownership and the 201 response
The create handler adds one document to a collection. It checks who is calling, validates the body, saves the document and answers 201 Created with the saved data. This page shows how to declare a create route, what a request and a response look like, and what the handler does for you along the way.
Declare a Create Route
A create route is a route whose handler is "create". Give it a validation group with validation, because a route with no validation accepts no body fields at all.
// 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() },
},
validations: {
create: {
name: z.string().min(1).max(120),
price: z.number().min(0),
sku: z.string().optional(),
},
},
routes: defineRoutes([
{
method: "post",
path: "/",
handler: "create",
auth: "authenticated",
roles: ["editor", "admin"],
validation: "create",
},
]),
});Pass products to new Forge({ collections: [products] }) as the Quickstart shows. The sku field is a custom field: when no helper in fields fits, you write its Mongoose half and its Zod half yourself (see Schemas).
| Key | Value here | What it does |
|---|---|---|
method and path | "post" and "/" | With the collection route, they make POST /api/v1/products |
handler | "create" | Saves one document |
auth | "authenticated" | The caller must be signed in |
roles | ["editor", "admin"] | The caller must also have one of these roles |
validation | "create" | The name of the rules in validations that check the body |
Send a Request
Send the document as JSON. A browser app sends the access token cookie that sign-in set, so it adds nothing. Other clients send the token in an Authorization header:
POST /api/v1/products
Authorization: Bearer eyJhbGciOiJIUzI1Ni…
Content-Type: application/json
{
"name": "Mechanical keyboard",
"price": 89.9,
"sku": "KB-001"
}HTTP/1.1 201 Created
Content-Type: application/json
{
"success": true,
"message": "Product created 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 status is 201. The message is the model name followed by created successfully., and data is the saved document with its new _id and its timestamps. Fields that must stay private, such as __v, are left out.
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 body is checked with the route's validation group. A failure answers 422, and fields the group does not list are dropped.
- Fields that only administrators may write,
roleby default, are removed for everyone else. - Uploaded files are stored, when the route declares
files. - Fields named in
encryptedFieldsandhashedFieldsare encrypted or hashed. - The
ownerfield is set to the signed-in user, when the schema has one. - The document is saved, and private fields are removed from the result.
- Your
afteractions run, and the handler answers201.
Two of these steps protect you from clients that send more than you expect. Fields missing from the validation group are dropped, so a client cannot write a field you did not list. Protected fields are removed for everyone but administrators, so a signed-in user cannot become an administrator by sending "role": "admin". See Actions for the before and after hooks.
Choose Who Can Create
The auth key on the route decides who gets past step 1:
"authenticated"lets any signed-in user create documents. This is the default for CRUD routes."authenticated"withroleslimits creation to people who have one of those roles."admin"limits creation to administrators."public"lets anyone create documents, including visitors who are not signed in.
A public create route lets anyone write to your database. Keep its validation group strict, because the only rate limit on it is the global one (maxReqLimit, 100 requests per 15 minutes by default).
The Authorization page explains each level, how roles are checked and how protected fields work.
Validation Errors
When the body breaks a rule, the handler answers 422 with the code VALIDATION_FAILED. The message names the first problem, and issues lists every problem with the path of the field:
POST /api/v1/products
Authorization: Bearer eyJhbGciOiJIUzI1Ni…
Content-Type: application/json
{
"name": "",
"price": -5
}HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"success": false,
"message": "name: Too small: expected string to have >=1 characters",
"code": "VALIDATION_FAILED",
"issues": [
{
"path": "name",
"message": "Too small: expected string to have >=1 characters",
"code": "too_small"
},
{
"path": "price",
"message": "Too small: expected number to be >=0",
"code": "too_small"
}
],
"requestId": "75d55dde-15df-4930-929e-d3302defddf4"
}Zod writes these messages. To change what a client sees, pass your own text to the rule, for example z.string().min(1, "Name is required"). The Validation page covers every rule type.
Other Errors
A create request can fail in other ways, and each has its own status and code. A field declared unique in the schema gives a 409 when the value already exists:
HTTP/1.1 409 Conflict
Content-Type: application/json
{
"success": false,
"message": "An item with this sku already exists.",
"code": "DUPLICATE_KEY",
"hint": "'sku' is declared unique in the schema.",
"details": {
"fields": [
"sku"
]
},
"requestId": "a2fcbda1-f1d5-4f1b-9b17-89b85374d2fe"
}| Status | code | When it happens | What to change |
|---|---|---|---|
| 400 | INVALID_JSON | The body is not valid JSON | Send well-formed JSON with Content-Type: application/json |
| 401 | ACCESS_TOKEN_MISSING | No token or cookie was sent | Sign in first, then send the token or the cookie |
| 403 | ROLE_NOT_ALLOWED | The caller's role is not in roles | Sign in with an allowed role, or add the role to roles |
| 403 | CSRF_ORIGIN_REJECTED | A browser request came from a site that is not in frontendURL | Add the site to frontendURL or frontendURLs |
| 409 | DUPLICATE_KEY | A field declared unique already has that value | Send a different value |
| 413 | PAYLOAD_TOO_LARGE | The body is larger than bodyLimit, 100kb by default | Send less data, or raise bodyLimit |
| 422 | VALIDATION_FAILED | The body breaks a validation rule | Fix the fields listed in issues |
The hint and details keys appear only when exposeErrorDetails is on, which is the default outside production. Every error also carries a requestId, the same value as the X-Request-Id response header, so you can find the matching line in your server log.
Owned and Encrypted Documents
Two schema choices change what create stores. A field named owner is filled from the session, and a field listed in the route's encryptedFields is sealed with your master key before it is saved.
// src/collections/notes.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
import { z } from "zod";
export const notes = collection({
type: "crud",
route: "notes",
model: "Note",
schema: {
title: fields.requiredString,
body: fields.optionalString,
secret: fields.encryptedString,
owner: fields.userRef,
},
validations: {
create: {
title: z.string().min(1),
body: z.string().optional(),
secret: z.string().optional(),
},
},
routes: defineRoutes([
{
method: "post",
path: "/",
handler: "create",
auth: "authenticated",
validation: "create",
encryptedFields: ["secret?"],
},
]),
});owner is not in the validation group, so a client cannot choose it. The handler sets it to the signed-in user, which is what lets admin-or-owner routes keep each person's notes private. The question mark in "secret?" means the field is optional. Encrypting a field needs the masterKey option, and new Forge throws a configuration error without it.
POST /api/v1/notes
Authorization: Bearer eyJhbGciOiJIUzI1Ni…
Content-Type: application/json
{
"title": "Shopping list",
"body": "Milk, eggs",
"secret": "my bank PIN is 1234"
}HTTP/1.1 201 Created
Content-Type: application/json
{
"success": true,
"message": "Note created 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"
}
}The response does not echo secret. A read route returns it in plain text only when it lists the field in decryptedFields; Hidden and Encrypted Fields explains both settings, and the Authorization page explains how the owner field is used.
Where to Go Next
Bulk Create
Insert many documents in one request.
Validation
Write the rules that check every request body.
:::