PocketBase Hooks
PocketBase v0.36 includes an embedded JavaScript VM (JSVM) that executes hook files from the pb_hooks/ directory. Hooks allow you to run logic directly inside the PocketBase process — before and after collection CRUD events, on a cron schedule, or at a custom HTTP route. For VeloCMS, hooks are the right tool for database-level side effects that cannot wait for Next.js (e.g. denormalization, internal consistency maintenance). They are the wrong tool for business logic that needs npm packages, fetch, or React.
The JSVM runtime
PocketBase's JSVM is goja — a Go-based ECMAScript 5.1+ implementation with select ES2020+ extensions. It is not Node.js. It does not have access to the Node.js standard library, npm modules, or browser globals. It provides PocketBase-specific globals ($app, $http, $os, etc.) and access to all PocketBase Go packages via thin JavaScript bindings.
| Available in JSVM | NOT available in JSVM |
|---|---|
| $app — PocketBase app instance | require() / import (no module system) |
| $http.send() — outbound HTTP | npm packages |
| $os.getenv() — environment variables | Node.js fs, crypto, path, etc. |
| $mails.send() — email via PB mailer | fetch() (use $http.send instead) |
| cronAdd() — cron scheduler | async/await (use callbacks) |
| routerAdd() — custom HTTP routes | Promise |
| onRecordCreate/Update/Delete hooks | Streams or Buffer |
Hook file structure
All hook files live in pb/pb_hooks/. PocketBase loads every .js file in that directory on startup. Each file should focus on one concern — there is no module system to split files, so naming convention is the only organization tool. VeloCMS uses the pattern {collection}-{concern}.pb.js (e.g. posts-denormalize.pb.js, members-sync.pb.js).
// Runs BEFORE a post record is created.
// Use e.record to read/modify the record before save.
// Call e.next() to continue — omitting it blocks the operation.
onRecordCreateRequest(function(e) {
// Auto-set published_at when status transitions to "published"
if (e.record.get("status") === "published" && !e.record.get("published_at")) {
e.record.set("published_at", new Date().toISOString());
}
e.next();
}, "posts");
// Runs AFTER a post record is updated.
onRecordUpdateRequest(function(e) {
e.next(); // Must call next() even in after-hooks
// Log to console — visible in PocketBase server output
console.log("post updated:", e.record.id);
}, "posts");Available hook events
| Hook | When it fires | Collection arg |
|---|---|---|
| onRecordCreateRequest | Before record create, can modify/block | Required |
| onRecordAfterCreateRequest | After record create, record has ID | Required |
| onRecordUpdateRequest | Before record update | Required |
| onRecordAfterUpdateRequest | After record update | Required |
| onRecordDeleteRequest | Before record delete, can block | Required |
| onRecordAfterDeleteRequest | After record delete | Required |
| onModelBeforeCreate | Before any model create (lower level) | None |
| onTerminate | On PocketBase shutdown | None |
Cron jobs in hooks
PocketBase hooks support cron scheduling via cronAdd(). The cron expression follows standard 5-field format. Cron jobs run inside the PocketBase process and have access to all JSVM globals. They are suitable for lightweight maintenance tasks like expiring old records or sending status pings.
// Run every day at 03:00 UTC
cronAdd("cleanup-expired-tokens", "0 3 * * *", function() {
var expiryThreshold = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
try {
var records = $app.dao().findRecordsByFilter(
"verification_tokens",
"expires_at < {:threshold}",
"-created",
100,
0,
{ threshold: expiryThreshold }
);
records.forEach(function(record) {
$app.dao().deleteRecord(record);
});
} catch (err) {
console.error("cleanup cron error:", err);
}
});Custom HTTP routes
routerAdd() registers a custom HTTP route on the PocketBase server. This is useful for lightweight webhooks or internal endpoints that need direct database access without going through Next.js. The route handler receives an echo.Context (the underlying Go web framework), and you access request/response via e.request() and e.json().
routerAdd("GET", "/api/internal/health", function(e) {
e.json(200, { status: "ok", ts: new Date().toISOString() });
}, /* optional middleware */ );Outbound HTTP from hooks
Use $http.send() to make outbound HTTP calls from a hook. The function is synchronous — it blocks the hook goroutine until complete. Keep outbound calls short (under 5 seconds) to avoid blocking other PocketBase operations. For long-running side effects, post a message to a queue or write a job record to PocketBase and let Next.js pick it up via a polling cron.
// Notify Next.js of a record change (fire-and-forget style)
onRecordAfterCreateRequest(function(e) {
e.next();
try {
$http.send({
url: $os.getenv("NEXTJS_INTERNAL_URL") + "/api/internal/notify",
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collection: "posts", id: e.record.id }),
timeout: 3, // seconds
});
} catch (err) {
// Non-fatal — do not block the hook response
console.warn("notify failed:", err);
}
}, "posts");Security model
Hook files run with full access to the PocketBase database — they bypass API rules. This makes hooks powerful but dangerous: a hook that reads from a collection without checking tenant_id can accidentally expose cross-tenant data. Always filter by tenant_id in hook database queries, just as you would in application code. Never expose raw secrets from $os.getenv() in HTTP responses or logs.
- Hooks bypass PocketBase API rules — filter by tenant_id explicitly in every query
- Never log raw secrets obtained from $os.getenv()
- Keep hook files focused — one concern per file, named {collection}-{concern}.pb.js
- Keep outbound HTTP calls under 5 seconds — long blocks degrade all PocketBase operations
- For logic requiring npm packages: write to a job record, pick it up from Next.js
- Do not use $os.exec() — arbitrary shell execution in the PocketBase process is unsafe
- Test hooks by running the PocketBase binary locally: ./pocketbase serve --dir pb_data
When to use hooks vs Next.js
| Use hooks when | Use Next.js server actions / route handlers when |
|---|---|
| Logic must run inside the DB transaction | Logic needs npm packages |
| Simple denormalization on save | Complex business logic with multiple conditions |
| Lightweight scheduled cleanup | Logic needs Resend, Stripe, or other SDKs |
| Internal PocketBase-only operations | Logic needs React Email rendering |
| You need to block a record save (validation) | You need to respond to a user action (button click) |
Reference
| Resource | Link |
|---|---|
| pb/pb_hooks/ | All hook files in the VeloCMS repo |
| PocketBase JSVM docs | https://pocketbase.io/docs/js-overview/ |
| PocketBase hooks reference | https://pocketbase.io/docs/js-collections/ |
| Cron expression format | Standard 5-field: minute hour day month weekday |