| name | PocketBase Hooks |
| description | 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(200, { "message": "Hello " + name })
}, )
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
var body = new DynamicModel({ name: "", age: 0 })
e.bindBody(body)
var page = e.request.url.query().get("page")
var token = e.request.header.get("Authorization")
var files = e.findUploadedFiles("document")
var user = e.auth
var isSuper = e.hasSuperuserAuth()
Middleware
Built-in middleware
routerAdd("GET", "/api/protected", handler,
$apis.requireAuth(),
$apis.requireAuth("users"),
$apis.requireSuperuserAuth(),
$apis.requireGuestOnly(),
$apis.bodyLimit(5 * 1024 * 1024),
$apis.gzip()
)
Global middleware
routerUse(function(e) {
console.log(e.request.method, e.request.url.path)
return e.next()
})
Custom route middleware
function myMiddleware(e) {
var result = e.next()
return 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
onRecordCreateExecute(function(e) {
e.record.set("status", "pending")
return e.next()
}, "posts")
onRecordAfterCreateSuccess(function(e) {
console.log("Created:", e.record.id)
}, "posts")
onRecordAfterCreateError(function(e) {
console.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) {
return e.next()
}, "users")
onRecordAuthWithOAuth2Request(function(e) {
return e.next()
}, "users")
onRecordAuthWithOTPRequest(function(e) {
return e.next()
}, "users")
onRecordAuthRefreshRequest(function(e) {
return e.next()
}, "users")
Realtime hooks
onRealtimeConnectRequest(function(e) {
return e.next()
})
onRealtimeSubscribeRequest(function(e) {
return e.next()
})
Other hooks
onFileDownloadRequest(function(e) {
return e.next()
}, "documents")
onBatchRequest(function(e) {
return e.next()
})
onCollectionCreateExecute(function(e) {
return e.next()
})
onBootstrap(function(e) {
return e.next()
})
onTerminate(function(e) {
return e.next()
})
Validation hook
onRecordValidate(function(e) {
if (e.record.getString("title").length < 3) {
e.error = new ValidationError("title", "Title must be at least 3 characters")
}
return e.next()
}, "posts")
Enrich hook (modify API response)
onRecordEnrich(function(e) {
if (!e.requestInfo.auth || e.requestInfo.auth.id !== e.record.getString("author")) {
e.record.hide("private_notes")
}
e.record.withCustomData(true)
e.record.set("displayName", e.record.getString("first") + " " + e.record.getString("last"))
return e.next()
}, "users")
Database
Query builder
var results = arrayOf(new DynamicModel({ id: "", title: "", count: 0 }))
$app.db()
.select("id", "title", "COUNT(comments) as count")
.from("posts")
.where($dbx.hashExp({ status: "active" }))
.andWhere($dbx.like("title", "hello"))
.orderBy("created DESC")
.limit(10)
.offset(0)
.all(results)
Execution methods
| Method | Returns |
|---|
.all(results) | Populates array |
.one(result) | Single record |
.execute() | For INSERT/UPDATE/DELETE |
Raw queries
$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" })
$dbx.hashExp({ field: ["a", "b"] })
$dbx.not($dbx.hashExp({ field: "value" }))
$dbx.and(expr1, expr2)
$dbx.or(expr1, expr2)
$dbx.like("field", "val")
$dbx.orLike("field", "a", "b")
$dbx.notLike("field", "val")
$dbx.in("field", "a", "b", "c")
$dbx.notIn("field", "a", "b")
$dbx.between("field", 1, 10)
$dbx.exists($dbx.exp())
$dbx.(, optionalParams)
Transactions
$app.runInTransaction(function(txApp) {
var record = txApp.findRecordById("posts", "RECORD_ID")
record.set("views", record.getInt("views") + 1)
txApp.save(record)
})
Record Operations
Find records
var record = $app.findRecordById("posts", "RECORD_ID")
var record = $app.findFirstRecordByData("users", "email", "user@example.com")
var record = $app.findFirstRecordByFilter("posts", "slug = {:slug}", { slug: "my-post" })
var records = $app.findRecordsByFilter(
"posts",
"status = 'active'",
"-created",
10,
0
)
var records = $app.findAllRecords("posts", $dbx.hashExp({ status: "active" }))
var total = $app.countRecords("posts", $dbx.hashExp({ status: "active" }))
Create records
var collection = $app.findCollectionByNameOrId("posts")
var record = new Record(collection)
record.set("title", "My Post")
record.set("author", "USER_ID")
record.set("tags", ["tag1", "tag2"])
$app.save(record)
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")
record.getDateTime("created")
record.get("field")
Expand relations
$app.expandRecord(record, ["author", "tags"], null)
var author = record.expandedOne("author")
var tags = record.expandedAll("tags")
File operations
var file = $filesystem.fileFromPath("/path/to/file.pdf")
record.set("document", file)
var file = $filesystem.fileFromBytes(byteArray, "report.pdf")
record.set("document", file)
var file = $filesystem.fileFromURL("https://example.com/file.pdf")
record.set("document", file)
$app.save(record)
Cron Jobs
cronAdd("daily_cleanup", "0 3 * * *", function() {
var old = $app.findRecordsByFilter("temp", "created < @now - 30d", "", 0, 0)
for (var i = 0; i < old.length; i++) {
$app.delete(old[i])
}
})
cronRemove("daily_cleanup")
Cron expressions: minute hour day month weekday
Preview registered crons: Dashboard > Settings > Crons
Email
var message = new MailerMessage()
message.from = { address: $app.settings().meta.senderAddress, name: $app.settings().meta.senderName }
message.to = [{ address: "user@example.com", name: "User" }]
message.subject = "Hello"
message.html = "<h1>Hello World</h1>"
$app.newMailClient().send(message)
Customize system emails
onMailerRecordVerificationSend(function(e) {
e.message.subject = "Custom verification subject"
e.message.html = "<p>Custom HTML with token: " + e.meta.token + "</p>"
return e.next()
}, "users")
HTTP Client
var res = $http.send({
url: "https://api.example.com/data",
method: "POST",
body: JSON.stringify({ key: "value" }),
headers: { "Content-Type": "application/json", "Authorization": "Bearer TOKEN" },
timeout: 30
})
res.statusCode
res.json
res.headers
res.cookies
res.body
var formData = new FormData()
formData.append("file", $filesystem.fileFromPath("/path/to/file.pdf"))
formData.append("name", "test")
var res = $http.send({
url: "https://api.example.com/upload",
method: "POST",
body: formData
})
No streaming support in $http.send().
Error Types
throw new BadRequestError("message", optionalData)
throw new UnauthorizedError("message", optionalData)
throw new ForbiddenError("message", optionalData)
throw new NotFoundError("message", optionalData)
throw new TooManyRequestsError("message", optionalData)
throw new InternalServerError("message", optionalData)
throw new ApiError(statusCode, "message", optionalData)
new ValidationError("field_name", "error message")
Global Objects
| Object | Purpose |
|---|
$app | Main app instance — DB, records, collections, settings |
$apis | API middleware helpers |
$security | JWT, encryption, random string generation |
$os | OS operations: $os.exec(), $os.readDir(), $os.tempDir() |
$http | HTTP client |
$filesystem | File helpers (fileFromPath, fileFromBytes, fileFromURL) |
$dbx | SQL expression builders |
$security examples
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"])
var files = $os.readDir("/path")
var tmp = $os.tempDir("prefix")
Common Patterns
Auto-assign author on create
onRecordCreateExecute(function(e) {
if (e.auth) {
e.record.set("author", e.auth.id)
}
return e.next()
}, "posts")
Cascade custom logic on delete
onRecordDeleteExecute(function(e) {
var 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")
Rate limiting per user
routerAdd("POST", "/api/expensive-action", function(e) {
var recent = $app.countRecords("actions",
$dbx.hashExp({ user: e.auth.id }),
$dbx.exp("created > {:cutoff}", { cutoff: new DateTime().sub(1 * 60) })
)
if (recent >= 5) {
throw new TooManyRequestsError("Rate limit exceeded")
}
return e.json(200, { ok: true })
}, $apis.requireAuth())
Webhook on record change
onRecordCreateAfterSuccessExecute(function(e) {
try {
$http.send({
url: "https://hooks.example.com/webhook",
method: "POST",
body: JSON.stringify({
event: "record.create",
collection: e.record.collection().name,
record: e.record
}),
headers: { "Content-Type": "application/json" },
timeout: 10
})
} catch (err) {
console.log("Webhook failed:", err)
}
})