S
Get Started

Quickstart

Build a working products API with CRUD routes, validation and access control in a few minutes

In this guide you build a small products API. You describe one collection, start the server, call its five routes with curl, and then close the routes that change data to anonymous visitors. It takes about 10 minutes and assumes you finished Installation: Node.js 20 or newer, the packages installed, MongoDB running and an empty .env file. The examples are TypeScript without type annotations, so they also work as JavaScript.

The Plan

  1. 1
    1. Describe the products collection in src/collections/products.ts.
    2. Pass the collection to new Forge in src/server.ts and start the server.
    3. Call the routes with curl: list, create, read, update and delete.
    4. Require sign-in for writing, and administrator access for deleting.

Describe the Collection

A collection is one object that describes a resource: where it lives in the URL, how it is stored, how request bodies are checked and which routes exist. Create src/collections/products.ts:

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,
  },
  validations: {
    create: { name: z.string().min(1).max(120), price: z.number().min(0) },
    update: { name: z.string().min(1).max(120).optional(), price: z.number().min(0).optional() },
  },
  routes: defineRoutes([
    { method: "get", path: "/", handler: "readAll", auth: "public" },
    { method: "get", path: "/:id", handler: "read", auth: "public" },
    { method: "post", path: "/", handler: "create", auth: "public", validation: "create" },
    { method: "patch", path: "/update/:id", handler: "update", auth: "public", validation: "update" },
    { method: "delete", path: "/delete/:id", handler: "remove", auth: "public" },
  ]),
});
KeyWhat it does
type"crud" makes this a resource with create, read, update and delete handlers
routeThe URL segment. products is served at /api/v1/products
modelThe model name, singular and capitalized
schemaThe stored fields. fields.requiredString is a text field that must be present
validationsZod rules for request bodies, in groups that you name. A route picks one group with validation, and a route without validation accepts no body fields
routesThe endpoints. Each names a handler, an access level in auth and, when it reads a body, a validation group

Every route here has auth: "public", so anyone can call it. That makes the first run easy. Require Sign-in closes the write routes before you finish.

Start the Server

Create src/server.ts. It passes the collection to new Forge and starts the server:

TypeScript
// src/server.ts
import { Forge } from "@starklabs/forge";
import { products } from "./collections/products.js";

const forge = new Forge({
  collections: [products],
});

await forge.start();

The import ends in .js although the file is products.ts. TypeScript requires this for ES modules. Now start the server:

Bash
npm run dev

StarkForge checks the configuration, connects to MongoDB, mounts the routes and prints a banner. These are the lines to look for:

Text
[2026-01-15T10:30:00.000Z] DEBUG Mounted 2 collections under /api/v1.

│ Status           │ ● Running                             │
│ Environment      │ development                           │

│ Base URL         │ http://localhost:5000                 │
│ API Prefix       │ /api/v1                               │
│ Healthcheck      │ http://localhost:5000/api/v1/health   │

│ Driver / Name    │ MongoDB (starkForge)                  │
│ Connection       │ ✔ Connected                           │

│ Models           │ 1                                     │
│ Validators       │ 2                                     │
│ Routes           │ 10                                    │

│  Listening on: http://localhost:5000/api/v1/[route]      │

The counts include the health check, a built-in collection with one route for each HTTP method. Open a second terminal and call it:

Bash
curl http://localhost:5000/api/v1/health

The answer contains "status": "ok" and "database": "up", which means the server and the database both work.

Call the API

Each command below is followed by what StarkForge answers.

Shell Quoting

The commands pass JSON in single quotes, which works in macOS, Linux, WSL and Git Bash shells. Other shells need different quoting.

List the Products

Bash
curl http://localhost:5000/api/v1/products
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product list fetched successfully.",
  "data": [],
  "meta": {
    "page": 1,
    "limit": 50,
    "total": 0,
    "pages": 1
  }
}

The list is empty. The meta object describes the page: 50 documents per page by default, total documents in all.

Create a Product

Bash
curl -X POST http://localhost:5000/api/v1/products -H "Content-Type: application/json" -d '{"name": "Mechanical keyboard", "price": 89.9}'
Text
HTTP/1.1 201 Created
Content-Type: application/json

{
  "success": true,
  "message": "Product created successfully.",
  "data": {
    "name": "Mechanical keyboard",
    "price": 89.9,
    "_id": "665f1c2e8b3a4d0012a10001",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:30:00.000Z"
  }
}

StarkForge added _id, createdAt and updatedAt itself. Copy the _id from your own response, because the next commands need it.

Read One Product

Use your own _id in place of the sample value in the URL:

Bash
curl http://localhost:5000/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,
    "_id": "665f1c2e8b3a4d0012a10001",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:30:00.000Z"
  }
}

List the Products Again

Bash
curl http://localhost:5000/api/v1/products
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product list fetched successfully.",
  "data": [
    {
      "name": "Mechanical keyboard",
      "price": 89.9,
      "_id": "665f1c2e8b3a4d0012a10001",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-15T10:30:00.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 50,
    "total": 1,
    "pages": 1
  }
}

Update a Product

Send only the fields that change. Fields that are not in the update validation group are ignored.

Bash
curl -X PATCH http://localhost:5000/api/v1/products/update/665f1c2e8b3a4d0012a10001 -H "Content-Type: application/json" -d '{"price": 79.9}'
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product updated successfully.",
  "data": {
    "name": "Mechanical keyboard",
    "price": 79.9,
    "_id": "665f1c2e8b3a4d0012a10001",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:30:00.000Z"
  }
}

Delete a Product

Bash
curl -X DELETE http://localhost:5000/api/v1/products/delete/665f1c2e8b3a4d0012a10001
Text
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "message": "Product deleted successfully.",
  "data": {
    "name": "Mechanical keyboard",
    "price": 79.9,
    "_id": "665f1c2e8b3a4d0012a10001",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:30:00.000Z"
  }
}

The route answers with the document it removed.

What StarkForge Generated

Each entry in routes became one endpoint. You wrote no controllers, no model file and no validation middleware.

RouteHandlerAccess
GET /api/v1/productsreadAllpublic
GET /api/v1/products/:idreadpublic
POST /api/v1/productscreatepublic
PATCH /api/v1/products/update/:idupdatepublic
DELETE /api/v1/products/delete/:idremovepublic

The :id in a path stands for a MongoDB id, which is a string of exactly 24 hexadecimal characters. Any other value, such as /products/not-an-id, is answered with status 400 and the code INVALID_OBJECT_ID. Every request also passes through rate limits, secure headers, input sanitizing and the validation you declared, and every failure uses the same JSON error shape. How StarkForge Works follows one request through all of it.

Require Sign-in

Open Write Routes

As written, anyone who can reach your server can create, change and delete products. Do not deploy this version. The rest of this page closes those routes.

The auth key of a route sets its access level. Change the three routes that write data, and leave the two read routes public:

TypeScript
// src/collections/products.ts (excerpt)
routes: defineRoutes([
  { method: "get", path: "/", handler: "readAll", auth: "public" },
  { method: "get", path: "/:id", handler: "read", auth: "public" },
  { method: "post", path: "/", handler: "create", auth: "authenticated", validation: "create" },
  { method: "patch", path: "/update/:id", handler: "update", auth: "authenticated", validation: "update" },
  { method: "delete", path: "/delete/:id", handler: "remove", auth: "admin" },
]),

authenticated accepts any signed-in user and admin accepts only users whose role is admin. A route that needs a user also needs a way to sign in, so ask StarkForge to build one. Replace src/server.ts with this version:

TypeScript
// src/server.ts
import { Forge, fields } from "@starklabs/forge";
import { products } from "./collections/products.js";

const forge = new Forge({
  jwtSecret: process.env.JWT_SECRET,
  builtinConfig: {
    auth: {
      mode: "builtin",
      signupMode: "credentials",
      loginMode: "credentials",
      mongooseConfig: {
        model: "User",
        schema: {
          name: fields.requiredString,
          email: fields.email,
          password: fields.password,
          role: fields.role,
        },
      },
    },
  },
  collections: [products],
});

await forge.start();
OptionWhat it does
jwtSecretThe key that signs session tokens. It must have at least 32 characters
mode: "builtin"StarkForge generates the sign-up, sign-in, sign-out and token routes under /api/v1/auth
signupMode and loginMode"credentials" signs people in with an email and a password. The default, "otp", also emails a 6-digit code
mongooseConfigThe user model: its name and its fields. It needs an email field and a password field

Generate a secret, and save it in .env as JWT_SECRET:

Bash
node --input-type=module -e "import { generateJWTSecret } from '@starklabs/forge'; console.log(await generateJWTSecret());"
Bash
JWT_SECRET=paste-the-generated-secret-here

Stop the server and run npm run dev again, so that it reads the new .env. Then sign up. The option -c cookies.txt tells curl to save the session cookies that StarkForge sets:

Bash
curl -c cookies.txt -X POST http://localhost:5000/api/v1/auth/signup -H "Content-Type: application/json" -d '{"name": "Ada Lovelace", "email": "[email protected]", "password": "correct-horse-battery"}'
Text
HTTP/1.1 201 Created
Content-Type: application/json
Set-Cookie: accessToken=eyJhbGciOiJIUzI1Ni…; Max-Age=600; Path=/; HttpOnly; SameSite=Lax
Set-Cookie: refreshToken=eyJhbGciOiJIUzI1Ni…; Max-Age=2592000; Path=/; HttpOnly; SameSite=Lax

{
  "success": true,
  "message": "Signed up successfully.",
  "data": {
    "name": "Ada Lovelace",
    "email": "[email protected]",
    "_id": "665f1c2e8b3a4d0012a10009",
    "role": "user",
    "createdAt": "2026-01-15T10:30:00.000Z",
    "updatedAt": "2026-01-15T10:30:00.000Z"
  }
}

The new account has the role user, because role is a protected field that a sign-up request cannot choose. The access token cookie lasts 10 minutes. When it expires, sign in again with POST /api/v1/auth/login.

Now create a product with the cookies. The option -b cookies.txt sends them back:

Bash
curl -b cookies.txt -X POST http://localhost:5000/api/v1/products -H "Content-Type: application/json" -d '{"name": "Desk lamp", "price": 34}'

The answer is the same 201 as before. The same command without -b cookies.txt is refused:

Text
HTTP/1.1 401 Unauthorized
Content-Type: application/json

{
  "success": false,
  "message": "Authentication is required.",
  "code": "ACCESS_TOKEN_MISSING",
  "requestId": "0add8d45-8e76-4f44-8342-32dda7eab138"
}

Deleting needs more than a session. Ada has the role user, so her delete request fails:

Text
HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "success": false,
  "message": "Administrator access is required.",
  "code": "ADMIN_REQUIRED",
  "requestId": "72ff31ed-07b7-4736-b9eb-8428eea4cfda"
}

No request can set a role, so the first administrator is made in the database by changing a user's role to admin. The Authorization page shows how, and how to give other roles access to a route. The Authentication page covers emailed codes, magic links, Google, GitHub and two-factor codes.

Where to Go Next

CRUD Collections

See every handler and option for REST resources.

Authentication

Add emailed codes, magic links, Google and GitHub sign-in.

:::