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
- Describe the
productscollection insrc/collections/products.ts. - Pass the collection to
new Forgeinsrc/server.tsand start the server. - Call the routes with curl: list, create, read, update and delete.
- Require sign-in for writing, and administrator access for deleting.
- Describe the
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:
// 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" },
]),
});| Key | What it does |
|---|---|
type | "crud" makes this a resource with create, read, update and delete handlers |
route | The URL segment. products is served at /api/v1/products |
model | The model name, singular and capitalized |
schema | The stored fields. fields.requiredString is a text field that must be present |
validations | Zod 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 |
routes | The 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:
// 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:
npm run devStarkForge checks the configuration, connects to MongoDB, mounts the routes and prints a banner. These are the lines to look for:
[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:
curl http://localhost:5000/api/v1/healthThe 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.
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
curl http://localhost:5000/api/v1/productsHTTP/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
curl -X POST http://localhost:5000/api/v1/products -H "Content-Type: application/json" -d '{"name": "Mechanical keyboard", "price": 89.9}'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:
curl http://localhost:5000/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,
"_id": "665f1c2e8b3a4d0012a10001",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
}List the Products Again
curl http://localhost:5000/api/v1/productsHTTP/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.
curl -X PATCH http://localhost:5000/api/v1/products/update/665f1c2e8b3a4d0012a10001 -H "Content-Type: application/json" -d '{"price": 79.9}'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
curl -X DELETE http://localhost:5000/api/v1/products/delete/665f1c2e8b3a4d0012a10001HTTP/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.
| Route | Handler | Access |
|---|---|---|
GET /api/v1/products | readAll | public |
GET /api/v1/products/:id | read | public |
POST /api/v1/products | create | public |
PATCH /api/v1/products/update/:id | update | public |
DELETE /api/v1/products/delete/:id | remove | public |
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
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:
// 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:
// 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();| Option | What it does |
|---|---|
jwtSecret | The 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 |
mongooseConfig | The 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:
node --input-type=module -e "import { generateJWTSecret } from '@starklabs/forge'; console.log(await generateJWTSecret());"JWT_SECRET=paste-the-generated-secret-hereStop 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:
curl -c cookies.txt -X POST http://localhost:5000/api/v1/auth/signup -H "Content-Type: application/json" -d '{"name": "Ada Lovelace", "email": "ada@example.com", "password": "correct-horse-battery"}'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": "ada@example.com",
"_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:
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:
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:
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.
:::