Skip to content
Back to skills

Pb Hooks

ASecurity

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.

  • 30,495 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 29, 2026
ai-agentsjavascripttypescriptgojavasqlexpressapidatabasesecurity

Works with

  • cli
  • api

Security analysis

A100/100

Scanned May 29, 2026

npx -y skills add davila7/claude-code-templates --skill pb-hooks --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Pb Hooks?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Pb Hooks
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/davila7-pb-hooks/badge)](https://www.skillsdirectory.com/skills/davila7-pb-hooks)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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

```js
routerAdd("GET", "/api/hello/{name}", function(e) {
    var name = e.request.pathValue("name")
    return e.json(200, { "message": "Hello " + name })
}, /* 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

```js
// Body (JSON)
var body = new DynamicModel({ name: "", age: 0 })
e.bindBody(body)

// Query params
var page = e.request.url.query().get("page")

// Headers
var token = e.request.header.get("Authorization")

// Uploaded files
var files = e.findUploadedFiles("document")  // returns array of *filesystem.File

// Auth state
var user = e.auth          // current auth record or null
var isSuper = e.hasSuperuserAuth()
```

## Middleware

### Built-in middleware

```js
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

```js
routerUse(function(e) {
    // runs before every request
    console.log(e.request.method, e.request.url.path)
    return e.next()  // MUST call e.next() to continue
})
```

### Custom route middleware

```js
function myMiddleware(e) {
    // pre-processing
    var result = e.next()  // call next handler
    // post-processing
    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

```js
// Before/during create
onRecordCreateExecute(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 create
onRecordAfterCreateSuccess(function(e) {
    // e.record — the created record (has ID now)
    console.log("Created:", e.record.id)
}, "posts")

// After failed create
onRecordAfterCreateError(function(e) {
    // e.error — the error
    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

```js
onRecordAuthWithPasswordRequest(function(e) {
    // e.record — the auth record
    // e.password — the provided password
    return 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 login
    return e.next()
}, "users")

onRecordAuthWithOTPRequest(function(e) {
    // e.record — the auth record
    return e.next()
}, "users")

onRecordAuthRefreshRequest(function(e) {
    return e.next()
}, "users")
```

### Realtime hooks

```js
onRealtimeConnectRequest(function(e) {
    // e.client — the SSE client
    // e.idleTimeout — connection timeout
    return e.next()
})

onRealtimeSubscribeRequest(function(e) {
    // e.client
    // e.subscriptions — requested subscriptions
    return e.next()
})
```

### Other hooks

```js
onFileDownloadRequest(function(e) {
    // e.record, e.fileField, e.servedPath, e.servedName
    return e.next()
}, "documents")

onBatchRequest(function(e) {
    // e.batch — array of sub-requests
    return e.next()
})

onCollectionCreateExecute(function(e) {
    // e.collection
    return e.next()
})

// App lifecycle
onBootstrap(function(e) {
    // runs once on app start (after DB is ready)
    return e.next()
})

onTerminate(function(e) {
    // runs on graceful shutdown
    return e.next()
})
```

### Validation hook

```js
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)

```js
onRecordEnrich(function(e) {
    // Hide field from non-owners
    if (!e.requestInfo.auth || e.requestInfo.auth.id !== e.record.getString("author")) {
        e.record.hide("private_notes")
    }
    // Add computed field
    e.record.withCustomData(true)
    e.record.set("displayName", e.record.getString("first") + " " + e.record.getString("last"))
    return e.next()
}, "users")
```

## Database

### Query builder

```js
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)  // populates results array
```

### Execution methods

| Method | Returns |
|--------|---------|
| `.all(results)` | Populates array |
| `.one(result)` | Single record |
| `.execute()` | For INSERT/UPDATE/DELETE |

### Raw queries

```js
$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

```js
$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

```js
$app.runInTransaction(function(txApp) {
    // use txApp instead of $app inside transaction
    var record = txApp.findRecordById("posts", "RECORD_ID")
    record.set("views", record.getInt("views") + 1)
    txApp.save(record)
})
```

## Record Operations

### Find records

```js
// By ID
var record = $app.findRecordById("posts", "RECORD_ID")

// By field value
var 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 filter
var records = $app.findRecordsByFilter(
    "posts",                    // collection
    "status = 'active'",        // filter
    "-created",                 // sort
    10,                         // limit
    0                           // offset
)

// All records (no limit)
var records = $app.findAllRecords("posts", $dbx.hashExp({ status: "active" }))

// Count
var total = $app.countRecords("posts", $dbx.hashExp({ status: "active" }))
```

### Create records

```js
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"])  // multi-relation
$app.save(record)
// record.id is now set
```

### Update records

```js
var record = $app.findRecordById("posts", "RECORD_ID")
record.set("title", "Updated Title")
$app.save(record)
```

### Delete records

```js
var record = $app.findRecordById("posts", "RECORD_ID")
$app.delete(record)
```

### Record getters

```js
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

```js
$app.expandRecord(record, ["author", "tags"], null)
var author = record.expandedOne("author")   // single relation
var tags = record.expandedAll("tags")        // multi relation
```

### File operations

```js
// Assign file from path
var file = $filesystem.fileFromPath("/path/to/file.pdf")
record.set("document", file)

// Assign file from bytes
var file = $filesystem.fileFromBytes(byteArray, "report.pdf")
record.set("document", file)

// Assign file from URL
var file = $filesystem.fileFromURL("https://example.com/file.pdf")
record.set("document", file)

$app.save(record)
```

## Cron Jobs

```js
cronAdd("daily_cleanup", "0 3 * * *", function() {
    // runs every day at 3:00 AM
    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")  // remove a previously registered job
```

Cron expressions: `minute hour day month weekday`
Preview registered crons: Dashboard > Settings > Crons

## Email

```js
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>"
// message.bcc, message.cc — optional arrays
// message.attachments — optional

$app.newMailClient().send(message)
```

### Customize system emails

```js
onMailerRecordVerificationSend(function(e) {
    // e.record, e.message
    e.message.subject = "Custom verification subject"
    e.message.html = "<p>Custom HTML with token: " + e.meta.token + "</p>"
    return e.next()
}, "users")

// Similar hooks: onMailerRecordResetPasswordSend, onMailerRecordEmailChangeSend, onMailerRecordOTPSend
```

## HTTP Client

```js
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  // seconds
})

// Response
res.statusCode  // number
res.json         // parsed JSON (if applicable)
res.headers      // object
res.cookies      // object
res.body         // raw string

// Multipart upload
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

```js
throw new BadRequestError("message", optionalData)     // 400
throw new UnauthorizedError("message", optionalData)    // 401
throw new ForbiddenError("message", optionalData)       // 403
throw new NotFoundError("message", optionalData)        // 404
throw new TooManyRequestsError("message", optionalData) // 429
throw new InternalServerError("message", optionalData)  // 500
throw new ApiError(statusCode, "message", optionalData) // custom status

// Validation errors (for onRecordValidate)
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

```js
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

```js
var result = $os.exec("ls", ["-la", "/tmp"])  // returns { code, output }
var files = $os.readDir("/path")
var tmp = $os.tempDir("prefix")
```

## Common Patterns

### Auto-assign author on create

```js
onRecordCreateExecute(function(e) {
    if (e.auth) {
        e.record.set("author", e.auth.id)
    }
    return e.next()
}, "posts")
```

### Cascade custom logic on delete

```js
onRecordDeleteExecute(function(e) {
    // Clean up related data not handled by cascadeDelete
    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

```js
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) })  // last minute
    )
    if (recent >= 5) {
        throw new TooManyRequestsError("Rate limit exceeded")
    }
    // proceed with action
    return e.json(200, { ok: true })
}, $apis.requireAuth())
```

### Webhook on record change

```js
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)
    }
})
```

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.