Actions
Run your own code or send an email before or after a route does its work
An action is a function, or an email, that StarkForge runs around a built-in handler. Use one when a rule belongs to your app and not to the framework: refuse a request that breaks a business rule, add a field to the response, write an audit trail or notify someone. The handler keeps doing the standard work, which is validation, ownership, hidden fields and the response format, and your action adds its part at the right moment.
A First Action
This collection limits each user to three open tasks and adds a link to every task that it creates. The first action runs before the handler and the second one runs after it:
// src/collections/tasks.ts
import { collection, defineRoutes, fields, ForbiddenError } from "@starklabs/forge";
import { z } from "zod";
const MAX_OPEN_TASKS = 3;
export const tasks = collection({
type: "crud",
route: "tasks",
model: "Task",
schema: {
title: fields.requiredString,
done: fields.booleanFalse,
owner: fields.userRef,
},
validations: {
create: { title: z.string().trim().min(1).max(120) },
},
routes: defineRoutes([
{
method: "post",
path: "/",
handler: "create",
auth: "authenticated",
validation: "create",
actions: {
before: [
{
// Throwing here rejects the request before anything is saved.
async customAction(ctx) {
const open = await ctx.repository?.count({ owner: ctx.user?._id, done: false });
if ((open ?? 0) >= MAX_OPEN_TASKS) {
throw new ForbiddenError({
message: "Finish a task before you add another one.",
code: "TASK_LIMIT_REACHED",
details: { limit: MAX_OPEN_TASKS },
});
}
},
},
],
after: [
{
// A returned value replaces the data in the response.
customAction(ctx) {
const task = ctx.item as { _id: string };
return { ...task, url: `https://app.acme.example/tasks/${task._id}` };
},
},
],
},
},
]),
});actions sits on the route that it belongs to, with a before list and an after list. Each entry is an object with a customAction function or an emailAction. The first entry counts the open tasks of the signed-in user through ctx.repository and throws when the limit is reached, so nothing is saved. The second entry runs once the task is saved. It receives the saved task in ctx.item and returns a new object, which becomes data in the response.
A signed-in user creates a task:
POST /api/v1/tasks
Content-Type: application/json
{
"title": "Write docs"
}HTTP/1.1 201 Created
Content-Type: application/json
{
"success": true,
"message": "Task created successfully.",
"data": {
"title": "Write docs",
"owner": "665f1c2e8b3a4d0012a10002",
"_id": "665f1c2e8b3a4d0012a10001",
"done": false,
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z",
"url": "https://app.acme.example/tasks/665f1c2e8b3a4d0012a10001"
}
}The same user already has three open tasks, so the next request is refused. The error is the one that the first action threw:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{
"success": false,
"message": "Finish a task before you add another one.",
"code": "TASK_LIMIT_REACHED",
"details": {
"limit": 3
},
"requestId": "4405261e-9df2-435f-9e08-91f83d92b74e"
}A before action runs before validation. A request with a blank title from a user who is at the limit gets the limit error, not a validation error. Treat ctx.req.body as untrusted input in a before action.
Before and After
The two lists behave differently, because an after action runs when the work is already done:
before | after | |
|---|---|---|
| Runs | First, before the document is loaded, validated or saved | Last, once the work is saved |
| A thrown error | Rejects the request. The client receives the error | Is logged and skipped. The response is still sent |
| A returned value | Ignored | Replaces data in the response |
Actions of one list run in the order of the array, one at a time, and StarkForge waits for each one to finish before it moves on. A slow action therefore makes the request slow.
An after failure is only logged because reporting an error for work that succeeded would mislead the client. When several after actions return a value, the last one wins, and each action receives the current replacement in ctx.result. A replacement changes only data: the status, the message and the meta of a list stay as the handler made them.
A before action may change ctx.req.body, and validation then checks the changed body. An exception that is not one of the error classes becomes a 500 error that reveals nothing to the client.
Actions are not part of a database transaction. When a before action throws, nothing was saved. When an after action fails, what the handler saved stays saved.
The Order of Events
For create, a request moves through these stages:
- 1
- StarkForge checks the access level and the roles of the route.
- The
beforeactions run, in the order that you list them. - The body is validated, and the handler stores uploaded files, encrypts and hashes fields, stamps the owner and saves the document.
- The
afteractions run, in the order that you list them. - The response is sent.
The other handlers follow the same pattern. read, update and remove load the document after the before actions, and the ownership check happens when the document is loaded.
On read, update and remove, the before actions run before the document is loaded, and so before the ownership check. Do not use the id from the URL in a before action to read or change private data.
Where Actions Run
Actions work on the handlers below. ctx.operation tells an action which one is running:
| Handlers | ctx.operation |
|---|---|
read, readAll, create, createBulk, update, remove, removeMultiple, removeAll | The name of the handler |
addFile, updateFile, deleteFile | The name of the handler |
updateMe, deleteMe of the account collection | update and remove |
The sign-in handlers, getMe, checkRole and the health handlers do not run actions.
The Action Context
Every action receives one argument, the context. This table shows what it holds:
| Property | Available | Meaning |
|---|---|---|
req | Always | The Express request. In a before action, req.body is the raw body as the client sent it |
user | On routes that need sign-in | The signed-in user, with _id and role. A public route never sets it, even when the client sends a session |
route | Always | The route segment of the collection, such as tasks |
operation | Always | What the handler is doing, such as create or readAll |
model | Always | The model name, such as Task |
repository | Always | Database access for the model of this route, with findById, findOne, find, count, create, createMany, updateById, updateMany, deleteById and deleteMany |
repositoryOf | Always | A function that returns the repository of any model by its name, such as ctx.repositoryOf("Product"). It throws when no collection declares that model, and it works with the in-memory data source of createTestForge. The documents are raw, so remove private fields before they reach a response |
Model | With the default MongoDB data source | The Mongoose model, for queries that the repository does not cover |
item | In after | The document, or the list of documents, that the handler produced, without private fields. For removeMultiple and removeAll it is { deleted } |
result | In after | The data that will be sent: item, or the replacement from the previous action |
data.body | In after of create and update | The validated body, before encryption and hashing |
data.files | In after of create, update and the file handlers | The files that were stored |
ctx.data.body holds the values as the client sent them, before encryption and hashing, so it can contain a plain password or another secret. Never log it and never pass it to another service.
Sending an Email
An emailAction sits in the same lists as a customAction. This route saves a subscriber and then sends a welcome email to the address in the request:
// src/collections/subscribers.ts
import { collection, defineRoutes, fields } from "@starklabs/forge";
export const subscribers = collection({
type: "crud",
route: "subscribers",
model: "Subscriber",
schema: { name: fields.requiredString, email: fields.email },
validations: { create: { name: fields.requiredString, email: fields.email } },
routes: defineRoutes([
{
method: "post",
path: "/",
handler: "create",
auth: "public",
validation: "create",
actions: {
after: [
{
emailAction: {
from: "system-email-sender",
to: (ctx) => String(ctx.data?.body?.email),
subject: "Welcome to Acme",
type: "template",
template: {
name: "welcome",
welcome: {
userName: (ctx) => String(ctx.data?.body?.name),
userEmail: (ctx) => String(ctx.data?.body?.email),
dashboardUrl: "https://app.acme.example/dashboard",
},
},
},
},
],
},
},
]),
});HTTP/1.1 201 Created
Content-Type: application/json
{
"success": true,
"message": "Subscriber created successfully.",
"data": {
"name": "Grace Hopper",
"email": "[email protected]",
"_id": "665f1c2e8b3a4d0012a10001",
"createdAt": "2026-01-15T10:30:00.000Z",
"updatedAt": "2026-01-15T10:30:00.000Z"
}
}from: "system-email-sender" uses the systemEmailSender option. The to, subject and placeholder values can be functions of the context. In an after list, a failed send is logged and the response is still sent, because the subscriber is already saved. In a before list, the same failure rejects the request. Emails covers templates, placeholders and providers.
An action is a plain object, so you can define it once in its own module and list it on every route that needs it.
A Malformed Action
StarkForge checks the shape of every action when it builds the app. An entry that has neither customAction nor emailAction stops the server before it listens, and the message names the route and the list:
ConfigurationError (CONFIGURATION_ERROR): Invalid 'things' collection:
- GET /things/ → actions.before: an action must be { customAction: fn } or { emailAction: {...} }.Where to Go Next
Errors
Throw the right error class from an action and see what the client receives.
CRUD Collections
See every handler that actions can wrap.
:::