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 to manage. cronAdd lets a hook
file define its own cron jobs alongside the dashboard’s SQL-based cron
jobs, and onQueueJob/$queue.enqueue do
the same for the job queue — a background job
handled entirely in JavaScript, no Rust code required. 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. $mails.send
called from the same kind of hook (e.g. onRecordAfterCreateSuccess) is
transaction-aware too, but the other way around: rather than joining the
open transaction, it defers its _mailLog write and the actual delivery
until that transaction commits, so the send neither blocks on nor
competes with it, and never happens at all if the write rolls back. No
pb_hooks/ directory, or an empty one, is a complete no-op outside
--dev (see below). GET /api/functions gives a read-only view of what’s
currently registered.
No restart-to-reload, but only in --dev/CB_DEV=true. A dev
server started with --dev always runs the JS runtime, even against an
empty pb_hooks/, and polls the directory once a second: adding,
editing or deleting a *.pb.js file — lifecycle hooks, routerAdd
routes (including path params and middleware-style ordering),
cronAdd jobs, and onQueueJob handlers alike — takes effect on the
next reload with no restart, and a removed routerAdd/cronAdd/
onQueueJob registration stops matching/firing rather than lingering. Outside --dev (a production boot), the runtime
only starts when pb_hooks/ or pb_migrations/ actually has a file, and
changes to pb_hooks/*.pb.js need a restart to take effect, same as any
other config file.
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 });});
onQueueJob("send-welcome-email", (e) => { sendEmail(e.payload.userId); // throw to retry; return to mark it completed});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.
Migrations
Section titled “Migrations”Drop a *.js file in pb_migrations/ (or point CB_MIGRATIONS_DIR at
it) for the same migrate((app) => { ... }, (app) => { ... }) API
PocketBase uses:
/// <reference path="../pb_data/types.d.ts" />migrate((app) => { const collection = new Collection({ name: "widgets", type: "base", fields: [{ name: "title", type: "text" }], }); app.save(collection);}, (app) => { const collection = app.findCollectionByNameOrId("widgets"); app.delete(collection);});Pending migrations run automatically on serve startup (right after the
built-in schema migrations, before the server starts accepting requests)
and from cratebase migrate up/migrate down [n] — the JS runtime
starts for this even if pb_hooks/ has no file at all. Each migration’s
up/down runs in its own transaction ($app.runInTransaction
implicitly wraps it), so a migration that throws rolls back and is never
recorded as applied. Applied migrations are tracked in the same
_migrations table the built-in migrations use, distinguished by their
.js filename.
cratebase migrate create <name>scaffolds apb_migrations/<timestamp>_<name>.jsstub.cratebase migrate collectionswrites a migration that snapshots every current (non-system) collection — handy for seeding a fresh environment with your exact schema instead of hand-writing it.cratebase migrate history-syncdrops ledger rows whose file no longer exists inpb_migrations/.
Automigrate
Section titled “Automigrate”With --automigrate/CB_AUTOMIGRATE on (the default), every collection
created, updated or deleted through the dashboard/API writes a
pb_migrations/<timestamp>_<created|updated|deleted>_<name>.js file for
you and records it as already applied — so a schema change made by
clicking around the dashboard is still reproducible from pb_migrations/
alone. A file is only actually written once pb_migrations/ already
exists (create the directory once to opt in) or --dev/CB_DEV is set,
so a plain production install never grows a surprise top-level directory
the first time someone edits a collection.