S
Crud

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.

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() },
  },
  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).

KeyValue hereWhat 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:

Text
POST /api/v1/products
Authorization: Bearer eyJhbGciOiJIUzI1Ni…
Content-Type: application/json

{
  "name": "Mechanical keyboard",
  "price": 89.9,
  "sku": "KB-001"
}
Text
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. 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 body is checked with the route's validation group. A failure answers 422, and fields the group does not list are dropped.
    4. Fields that only administrators may write, role by default, are removed for everyone else.
    5. Uploaded files are stored, when the route declares files.
    6. Fields named in encryptedFields and hashedFields are encrypted or hashed.
    7. The owner field is set to the signed-in user, when the schema has one.
    8. The document is saved, and private fields are removed from the result.
    9. Your after actions run, and the handler answers 201.

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" with roles limits 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.
Open Create Routes

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:

Text
POST /api/v1/products
Authorization: Bearer eyJhbGciOiJIUzI1Ni…
Content-Type: application/json

{
  "name": "",
  "price": -5
}
Text
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:

Text
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"
}
StatuscodeWhen it happensWhat to change
400INVALID_JSONThe body is not valid JSONSend well-formed JSON with Content-Type: application/json
401ACCESS_TOKEN_MISSINGNo token or cookie was sentSign in first, then send the token or the cookie
403ROLE_NOT_ALLOWEDThe caller's role is not in rolesSign in with an allowed role, or add the role to roles
403CSRF_ORIGIN_REJECTEDA browser request came from a site that is not in frontendURLAdd the site to frontendURL or frontendURLs
409DUPLICATE_KEYA field declared unique already has that valueSend a different value
413PAYLOAD_TOO_LARGEThe body is larger than bodyLimit, 100kb by defaultSend less data, or raise bodyLimit
422VALIDATION_FAILEDThe body breaks a validation ruleFix the fields listed in issues
Hints and Details

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.

TypeScript
// 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.

Text
POST /api/v1/notes
Authorization: Bearer eyJhbGciOiJIUzI1Ni…
Content-Type: application/json

{
  "title": "Shopping list",
  "body": "Milk, eggs",
  "secret": "my bank PIN is 1234"
}
Text
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.

:::