Skip to content

JavaScript hooks

Drop a *.pb.js file in pb_hooks/ next to your data directory (or point CB_HOOKS_DIR at it) and get PocketBase’s own hook API: onRecordCreate/onRecordUpdate-style lifecycle hooks and routerAdd for custom HTTP endpoints, both backed by an embedded QuickJS runtime (crates/jsvm) — no separate process, no restart-to-reload. cronAdd lets a hook file define its own cron jobs alongside the dashboard’s SQL-based cron jobs. A hook’s own $app.save/$app.delete inside a record-write-path hook joins that write’s own transaction, so a hook that aborts rolls the triggering write back too. No pb_hooks/ directory, or an empty one, is a complete no-op. GET /api/functions gives a read-only view of what’s currently registered.

A minimal pb_hooks/main.pb.js:

onRecordCreate((e) => {
if (!e.record.get("title")) {
throw new BadRequestError("title is required.");
}
e.next(); // let the framework's own insert run
console.log(`created post ${e.record.id}`);
}, "posts");
routerAdd("GET", "/api/stats/posts", (e) => {
const count = e.app.db().collection("posts").recordCount();
return e.json(200, { count });
});
cronAdd("nightly-cleanup", "0 3 * * *", () => {
$app.db().collection("_logs").deleteOld({ days: 30 });
});

e.next() is what actually runs the framework’s own action (here, the insert) — a handler that returns without calling it stops the chain, the same middleware model crates/server/src/hooks.rs documents for native Rust handlers, so a JS hook and a Rust plugin bound to the same event run in one priority-ordered chain rather than JS always going first or last. A hook that throws aborts the chain (and, on a write-path hook, rolls back the transaction it’s part of) and propagates as the request’s error response.