# Hooks & custom logic

> Run server-side logic on record events — in JavaScript or Go.

_Updated: 2026-06-10_

Hooks let you run logic when records change — validate input, derive fields, send
notifications, enforce invariants. Write them in JavaScript for quick iteration,
or in Go for type safety and performance.

## JavaScript hooks

Drop a `*.pb.js` file in `pb_hooks/` (next to where you run the binary;
override with `RAILBASE_HOOKS_DIR` or `hooks.dir` in `railbase.yaml`). It's
loaded by an embedded JS runtime at boot and hot-reloaded on save:

```js
// pb_hooks/posts.pb.js
$app.onRecordBeforeCreate("posts").bindFunc((e) => {
  const title = (e.record.title || "").trim();
  if (!title) throw new Error("title required");   // abort with 400
  e.record.title = title;                          // mutate before write
  e.next();
});

$app.onRecordAfterCreate("posts").bindFunc((e) => {
  console.log("post created:", e.record.id);
  e.next();
});
```

Events available: `onRecordBeforeCreate` / `AfterCreate`, `…BeforeUpdate` /
`AfterUpdate`, `…BeforeDelete` / `AfterDelete`.

The `$app` binding also exposes:

```js
$app.routerAdd("GET", "/api/hello/{name}", (e) =>
  e.json(200, { hello: e.pathParam("name") }));   // chi-style {param} paths
$app.cronAdd("nightly", "0 3 * * *", () => { /* … */ });
$app.onRequest((e) => { /* every request */ e.next(); });
```

> [!NOTE]
> A **Before** hook that throws aborts the write with a 400. An **After** hook
> throwing is logged but does not undo the committed write. Each handler is capped
> at ~5 seconds of wall-clock time. Rename a file to `*.disabled` to skip it.

## Go hooks

For compiled, typed logic, register from your `main` (see [Project setup](project-setup)).
The handler types live in `pkg/railbase/hooks`; a handler receives a
context plus the event, reads/mutates `ev.Record` (a `map[string]any`),
and returns `nil` to continue or an error (or `hooks.ErrReject`) to
refuse the write with a 400:

```go
import "github.com/railbase/railbase/pkg/railbase/hooks"

cli.ExecuteWith(func(app *railbase.App) {
    app.GoHooks().OnRecordBeforeCreate("posts",
        func(c *hooks.Context, ev *hooks.RecordEvent) error {
            title, _ := ev.Record["title"].(string)
            if title == "" {
                return fmt.Errorf("title required") // → 400 at the REST layer
            }
            ev.Record["title"] = strings.TrimSpace(title) // mutations persist
            return nil
        })
})
```

Before-hooks run synchronously and may mutate the record; after-hooks
run async, after commit. Pass `""` as the collection to hook every
collection. Use `c.Ctx` for timeouts and outbound calls.

## Which to use

- **JavaScript** — fast iteration, no rebuild, hot-reload. Great for validation,
  small derivations, glue.
- **Go** — type safety, access to the full `app` API, no per-call interpreter
  overhead. Reach for it for anything performance-sensitive or complex.

For background work that shouldn't block a request, use [Jobs & cron](jobs-and-cron)
instead of doing it inline in a hook.
