Skip to main content
PocketBase supports extending functionality using JavaScript through the JSVM (JavaScript Virtual Machine) plugin. This allows you to write hooks in JavaScript that run inside your PocketBase instance.

Overview

JavaScript hooks provide:
  • Hot reloading: Changes to your hooks are detected automatically (in dev mode)
  • No compilation: Write JavaScript and see results immediately
  • Full API access: Access to all PocketBase APIs through global bindings
  • TypeScript support: Full TypeScript definitions for autocomplete and type checking
  • npm packages: Use Node.js modules via require()

Setup

1

Enable JSVM plugin

The JSVM plugin is included in the default PocketBase executable. If you’re building from source, ensure it’s registered:
main.go
import (
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/plugins/jsvm"
)

func main() {
    app := pocketbase.New()
    
    jsvm.MustRegister(app, jsvm.Config{
        HooksDir:      "./pb_hooks",
        HooksWatch:    true,
        HooksPoolSize: 15,
    })
    
    app.Start()
}
2

Create hooks directory

Create a pb_hooks directory in your project root:
mkdir pb_hooks
3

Create a hook file

Create a file ending in .pb.js or .pb.ts:
touch pb_hooks/main.pb.js
4

Write your first hook

Add some code to your hook file:
pb_hooks/main.pb.js
onRecordCreate((e) => {
    console.log("Record created:", e.record.get("id"))
})

Configuration

The JSVM plugin accepts the following configuration options:
OptionTypeDefaultDescription
HooksDirstringpb_data/../pb_hooksDirectory containing JavaScript hooks
HooksWatchbooleanfalseEnable auto-restart on file changes
HooksFilesPatternstring^.*(\\.pb\\.js|\\.pb\\.ts)$Regex pattern for hook files
HooksPoolSizeint0Number of pre-warmed JS runtimes
MigrationsDirstringpb_data/../pb_migrationsDirectory for JS migrations
OnInitfunctionnilCallback to register custom bindings
Set HooksPoolSize to a positive number (e.g., 15) to improve performance by pre-warming JavaScript runtime instances.

Global bindings

PocketBase exposes several global objects and functions to your JavaScript hooks:

Core objects

BindingDescription
$appThe PocketBase app instance
$httpHTTP client for making requests
$osOperating system utilities
$securitySecurity and cryptography functions
$filepathFile path manipulation
$filesystemFile system operations
$mailsEmail sending utilities
$apisAPI helpers and middlewares
$dbxDatabase query builder helpers
$templateTemplate rendering

Hook registration functions

All event hooks are available as global functions:
// Model events
onModelCreate((e) => { /* ... */ })
onModelUpdate((e) => { /* ... */ })
onModelDelete((e) => { /* ... */ })

// Record events
onRecordCreate((e) => { /* ... */ }, "posts") // Optional tags
onRecordUpdate((e) => { /* ... */ })
onRecordDelete((e) => { /* ... */ })

// Auth events
onRecordAuthRequest((e) => { /* ... */ })
onRecordAuthWithPasswordRequest((e) => { /* ... */ })
onRecordAuthWithOAuth2Request((e) => { /* ... */ })

// Settings events
onSettingsListRequest((e) => { /* ... */ })
onSettingsUpdateRequest((e) => { /* ... */ })

// More events...
See the Event hooks reference for a complete list of available events.

Examples

Basic record hook

pb_hooks/posts.pb.js
// Validate post title before creation
onRecordCreate((e) => {
    const title = e.record.get("title")
    
    if (title.length < 5) {
        throw new BadRequestError("Title must be at least 5 characters")
    }
}, "posts")

// Log post updates
onRecordUpdate((e) => {
    console.log("Post updated:", e.record.get("id"))
}, "posts")

Send email on record creation

pb_hooks/notifications.pb.js
onRecordCreate((e) => {
    const message = new MailerMessage({
        from: {
            address: $app.settings().meta.senderAddress,
            name: $app.settings().meta.senderName,
        },
        to: [{address: e.record.get("email")}],
        subject: "Welcome!",
        html: "<p>Thanks for signing up!</p>",
    })
    
    $app.newMailClient().send(message)
}, "users")

Custom validation

pb_hooks/validation.pb.js
onRecordCreate((e) => {
    const age = e.record.get("age")
    const email = e.record.get("email")
    
    // Age validation
    if (age < 18) {
        throw new BadRequestError("You must be at least 18 years old")
    }
    
    // Email domain validation
    if (!email.endsWith("@company.com")) {
        throw new BadRequestError("Only company emails are allowed")
    }
}, "employees")

HTTP request

pb_hooks/webhooks.pb.js
onRecordCreate((e) => {
    const response = $http.send({
        url: "https://api.example.com/webhook",
        method: "POST",
        body: JSON.stringify({
            event: "record.created",
            record: e.record,
        }),
        headers: {
            "content-type": "application/json",
            "authorization": "Bearer SECRET_TOKEN",
        },
        timeout: 10, // seconds
    })
    
    console.log("Webhook response:", response.statusCode)
}, "posts")

Database queries

pb_hooks/stats.pb.js
onRecordCreate((e) => {
    // Count total posts by this author
    const authorId = e.record.get("author")
    const count = $app.findRecordsByFilter(
        "posts",
        `author = {:author}`,
        "-created",
        100,
        0,
        {author: authorId}
    ).length
    
    // Update author's post count
    const author = $app.findRecordById("authors", authorId)
    author.set("postCount", count)
    $app.save(author)
}, "posts")

Modify record before saving

pb_hooks/slugify.pb.js
onRecordCreate((e) => {
    // Auto-generate slug from title
    const title = e.record.get("title")
    const slug = title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, "-")
        .replace(/^-|-$/g, "")
    
    e.record.set("slug", slug)
}, "posts")

onRecordUpdate((e) => {
    // Update slug if title changed
    if (e.record.originalCopy().get("title") !== e.record.get("title")) {
        const title = e.record.get("title")
        const slug = title
            .toLowerCase()
            .replace(/[^a-z0-9]+/g, "-")
            .replace(/^-|-$/g, "")
        
        e.record.set("slug", slug)
    }
}, "posts")

Add custom routes

pb_hooks/routes.pb.js
routerAdd("GET", "/api/hello", (e) => {
    return e.json(200, {message: "Hello from JavaScript!"})
})

routerAdd("POST", "/api/stats/:collection", (e) => {
    const collection = e.request.pathValue("collection")
    
    const count = $app.findRecordsByFilter(
        collection,
        "",
        "-created",
        1,
        0
    ).totalItems
    
    return e.json(200, {
        collection: collection,
        totalRecords: count,
    })
}, $apis.requireAuth())

Scheduled tasks

pb_hooks/cron.pb.js
// Run every day at midnight
cronAdd("cleanup", "0 0 * * *", () => {
    // Delete old records
    const cutoff = new DateTime()
    cutoff.addDate(0, 0, -30) // 30 days ago
    
    const oldRecords = $app.findRecordsByFilter(
        "logs",
        `created < {:cutoff}`,
        "-created",
        100,
        0,
        {cutoff: cutoff.string()}
    )
    
    oldRecords.forEach((record) => {
        $app.delete(record)
    })
    
    console.log(`Deleted ${oldRecords.length} old log records`)
})

TypeScript support

PocketBase automatically generates TypeScript definitions in pb_data/types.d.ts. Use .pb.ts extensions for full type checking:
pb_hooks/typed.pb.ts
/// <reference path="../pb_data/types.d.ts" />

onRecordCreate((e) => {
    // TypeScript will provide autocomplete and type checking
    const record: core.Record = e.record
    const title: string = record.get("title")
    
    // Type-safe API calls
    $app.save(record)
}, "posts")

Using npm packages

You can use Node.js modules via require():
1

Initialize npm

cd pb_hooks
npm init -y
2

Install packages

npm install lodash validator
3

Use in hooks

pb_hooks/example.pb.js
const _ = require("lodash")
const validator = require("validator")

onRecordCreate((e) => {
    const email = e.record.get("email")
    
    if (!validator.isEmail(email)) {
        throw new BadRequestError("Invalid email address")
    }
    
    // Use lodash
    const userData = _.pick(e.record, ["name", "email", "age"])
    console.log("User data:", userData)
}, "users")

Best practices

Each hook file should handle a specific domain or feature. Split complex logic into multiple files:
pb_hooks/
├── auth.pb.js          # Authentication hooks
├── posts.pb.js         # Post-related hooks
├── notifications.pb.js # Email notifications
└── routes.pb.js        # Custom routes
Always handle errors appropriately:
onRecordCreate((e) => {
    try {
        // Your logic here
        const result = $http.send({...})
        
        if (result.statusCode !== 200) {
            throw new Error("External API failed")
        }
    } catch (err) {
        console.error("Hook error:", err)
        // Decide whether to throw or continue
        throw new BadRequestError("Failed to process request")
    }
})
JavaScript hooks run synchronously. For long-running operations, log and continue:
onRecordCreate((e) => {
    // Quick validation - blocking
    if (!e.record.get("email")) {
        throw new BadRequestError("Email required")
    }
    
    // Heavy operation - log for debugging
    console.log("Processing record:", e.record.id)
    
    // Don't block on external HTTP calls if possible
    // Consider using a queue or background job system
})
Use tags to filter which collections trigger your hooks:
// Only for posts collection
onRecordCreate((e) => {
    // ...
}, "posts")

// For multiple collections
onRecordCreate((e) => {
    // ...
}, "posts", "articles")

Debugging

Console logging

console.log("Debug message")
console.error("Error message")
console.warn("Warning message")

Inspect objects

onRecordCreate((e) => {
    console.log("Record:", JSON.stringify(e.record, null, 2))
    console.log("Original:", JSON.stringify(e.record.originalCopy(), null, 2))
})

Development mode

Run PocketBase with the --dev flag for verbose logging:
./pocketbase serve --dev

Next steps

Event hooks reference

See all available event hooks

Custom routes

Learn about custom API endpoints