Server-side JavaScript hooks for PocketBase (pb_hooks). Use when writing custom routes, event hooks, cron jobs, sending emails, making HTTP requests, querying the database, or extending PocketBase with server-side logic. Covers the goja ES5 runtime, routing, middleware, all event hooks, DB queries, record operations, and global APIs.
Server-side JavaScript hooks for PocketBase (pb_hooks). Use when writing custom routes, event hooks, cron jobs, sending emails, making HTTP requests, querying the database, or extending PocketBase with server-side logic. Covers the goja ES5 runtime, routing, middleware, all event hooks, DB queries, record operations, and global APIs.
PocketBase Server-Side JavaScript (pb_hooks)
Runtime Basics
Files go in pb_hooks/*.pb.js (must end with .pb.js)
Engine: goja — ES5.1 + some ES6. No ES6 modules (import/export), no async/await, no arrow functions in older versions. Use function(){} and CommonJS require().
Each file is loaded on app start and on hot-reload
__hooks — absolute path to the pb_hooks directory
TypeScript declarations: pb_data/types.d.ts (auto-generated, useful for IDE support)
--hooksPool=25 flag controls concurrent JS goroutines (default: 25)
Each handler runs in an isolated context — no shared mutable state between requests
Routing
Adding routes
routerAdd("GET", "/api/hello/{name}", function(e) {
var name = e.request.pathValue("name")
return e.json(, { : + name })
}, )
200
"message"
"Hello "
/* optional middleware */
Path patterns
{name} — named path parameter
{path...} — wildcard (matches rest of path)
{$} — exact match (no trailing slash)
Response methods
Method
Usage
e.json(status, data)
JSON response
e.string(status, text)
Plain text
e.html(status, html)
HTML response
e.redirect(status, url)
Redirect (301/302)
e.blob(status, contentType, bytes)
Binary data
e.stream(status, contentType, reader)
Streaming response
e.noContent(status)
No body (204)
Reading request data
// Body (JSON)var body = newDynamicModel({ name: "", age: 0 })
e.bindBody(body)
// Query paramsvar page = e.request.url.query().get("page")
// Headersvar token = e.request.header.get("Authorization")
// Uploaded filesvar files = e.findUploadedFiles("document") // returns array of *filesystem.File// Auth statevar user = e.auth// current auth record or nullvar isSuper = e.hasSuperuserAuth()
Middleware
Built-in middleware
routerAdd("GET", "/api/protected", handler,
$apis.requireAuth(), // any authenticated user// OR
$apis.requireAuth("users"), // only "users" collection// OR
$apis.requireSuperuserAuth(), // superusers only// OR
$apis.requireGuestOnly(), // unauthenticated only// OR
$apis.bodyLimit(5 * 1024 * 1024), // 5MB body limit// OR
$apis.gzip() // gzip compression
)
Global middleware
routerUse(function(e) {
// runs before every requestconsole.log(e.request.method, e.request.url.path)
return e.next() // MUST call e.next() to continue
})
Custom route middleware
functionmyMiddleware(e) {
// pre-processingvar result = e.next() // call next handler// post-processingreturn result
}
routerAdd("GET", "/api/test", handler, myMiddleware)
Priority: middleware runs in order — first registered, first executed.
Event Hooks
Record lifecycle
Each record event has 3 variants:
onRecord*Execute — wraps the default action. Call e.next() to proceed.
onRecord*AfterSuccess — runs after successful execution
onRecord*AfterError — runs after execution error
// Before/during createonRecordCreateExecute(function(e) {
// e.record — the record being created
e.record.set("status", "pending")
return e.next() // proceed with creation
}, "posts") // optional collection filter// After successful createonRecordAfterCreateSuccess(function(e) {
// e.record — the created record (has ID now)console.log("Created:", e.record.id)
}, "posts")
// After failed createonRecordAfterCreateError(function(e) {
// e.error — the errorconsole.log("Failed:", e.error)
}, "posts")
All record hooks
Hook
Event object fields
onRecordCreateExecute
e.record
onRecordUpdateExecute
e.record
onRecordDeleteExecute
e.record
onRecordAfterCreateSuccess
e.record — after successful create
onRecordAfterUpdateSuccess
e.record — after successful update
onRecordAfterDeleteSuccess
e.record — after successful delete
onRecordAfterCreateError
e.record, e.error — after failed create
onRecordAfterUpdateError
e.record, e.error — after failed update
onRecordAfterDeleteError
e.record, e.error — after failed delete
onRecordValidate
e.record — add custom validation errors
onRecordEnrich
e.record — modify API response (hide/add fields)
onRecordsListRequest
e.records, e.result — modify list response
onRecordRequestCreate
e.record — during API create request
onRecordRequestUpdate
e.record — during API update request
onRecordRequestDelete
e.record — during API delete request
Auth hooks
onRecordAuthWithPasswordRequest(function(e) {
// e.record — the auth record// e.password — the provided passwordreturn e.next()
}, "users")
onRecordAuthWithOAuth2Request(function(e) {
// e.record — the auth record (may be new)// e.oAuth2User — OAuth2 user data// e.isNewRecord — true if first OAuth2 loginreturn e.next()
}, "users")
onRecordAuthWithOTPRequest(function(e) {
// e.record — the auth recordreturn e.next()
}, "users")
onRecordAuthRefreshRequest(function(e) {
return e.next()
}, "users")
onFileDownloadRequest(function(e) {
// e.record, e.fileField, e.servedPath, e.servedNamereturn e.next()
}, "documents")
onBatchRequest(function(e) {
// e.batch — array of sub-requestsreturn e.next()
})
onCollectionCreateExecute(function(e) {
// e.collectionreturn e.next()
})
// App lifecycleonBootstrap(function(e) {
// runs once on app start (after DB is ready)return e.next()
})
onTerminate(function(e) {
// runs on graceful shutdownreturn e.next()
})
Validation hook
onRecordValidate(function(e) {
if (e.record.getString("title").length < 3) {
e.error = newValidationError("title", "Title must be at least 3 characters")
}
return e.next()
}, "posts")
$app.db().newQuery("SELECT * FROM posts WHERE status = {:status}")
.bind({ status: "active" })
.all(results)
Always use named params {:param} — never concatenate SQL strings.
$dbx expressions
$dbx.hashExp({ field: "value" }) // field = "value"
$dbx.hashExp({ field: ["a", "b"] }) // field IN ("a", "b")
$dbx.not($dbx.hashExp({ field: "value" })) // NOT (field = "value")
$dbx.and(expr1, expr2) // expr1 AND expr2
$dbx.or(expr1, expr2) // expr1 OR expr2
$dbx.like("field", "val") // field LIKE "%val%"
$dbx.orLike("field", "a", "b") // field LIKE "%a%" OR field LIKE "%b%"
$dbx.notLike("field", "val") // field NOT LIKE "%val%"
$dbx.in("field", "a", "b", "c") // field IN ("a", "b", "c")
$dbx.notIn("field", "a", "b") // field NOT IN ("a", "b")
$dbx.between("field", 1, 10) // field BETWEEN 1 AND 10
$dbx.exists($dbx.exp("SELECT 1 FROM t WHERE ..."))
$dbx.exp("raw SQL expression", optionalParams)
Transactions
$app.runInTransaction(function(txApp) {
// use txApp instead of $app inside transactionvar record = txApp.findRecordById("posts", "RECORD_ID")
record.set("views", record.getInt("views") + 1)
txApp.save(record)
})
Record Operations
Find records
// By IDvar record = $app.findRecordById("posts", "RECORD_ID")
// By field valuevar record = $app.findFirstRecordByData("users", "email", "user@example.com")
// By filter expression (same syntax as API rules)var record = $app.findFirstRecordByFilter("posts", "slug = {:slug}", { slug: "my-post" })
// Multiple records with filtervar records = $app.findRecordsByFilter(
"posts", // collection"status = 'active'", // filter"-created", // sort10, // limit0// offset
)
// All records (no limit)var records = $app.findAllRecords("posts", $dbx.hashExp({ status: "active" }))
// Countvar total = $app.countRecords("posts", $dbx.hashExp({ status: "active" }))
Create records
var collection = $app.findCollectionByNameOrId("posts")
var record = newRecord(collection)
record.set("title", "My Post")
record.set("author", "USER_ID")
record.set("tags", ["tag1", "tag2"]) // multi-relation
$app.save(record)
// record.id is now set
Update records
var record = $app.findRecordById("posts", "RECORD_ID")
record.set("title", "Updated Title")
$app.save(record)
Delete records
var record = $app.findRecordById("posts", "RECORD_ID")
$app.delete(record)
Record getters
record.id
record.getString("title")
record.getInt("count")
record.getFloat("price")
record.getBool("active")
record.getStringSlice("tags") // for multi-valued fields
record.getDateTime("created") // returns DateTime object
record.get("field") // raw interface{} value
Expand relations
$app.expandRecord(record, ["author", "tags"], null)
var author = record.expandedOne("author") // single relationvar tags = record.expandedAll("tags") // multi relation
var token = $security.randomString(32)
var hash = $security.hs256("data", "secret")
var encrypted = $security.encrypt("data", "encryptionKey")
var decrypted = $security.decrypt(encrypted, "encryptionKey")
$os examples
var result = $os.exec("ls", ["-la", "/tmp"]) // returns { code, output }var files = $os.readDir("/path")
var tmp = $os.tempDir("prefix")
onRecordDeleteExecute(function(e) {
// Clean up related data not handled by cascadeDeletevar comments = $app.findRecordsByFilter("comments", "post = {:id}", "-created", 0, 0, { id: e.record.id })
for (var i = 0; i < comments.length; i++) {
$app.delete(comments[i])
}
return e.next()
}, "posts")