Claude
Skills
Sign in
Back

PocketBase Hooks

Included with Lifetime
$97 forever

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.

Writing & Docs

What this skill does


# 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)          

Related in Writing & Docs