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
Enable JSVM plugin
The JSVM plugin is included in the default PocketBase executable. If you’re building from source, ensure it’s registered: 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 ()
}
Create hooks directory
Create a pb_hooks directory in your project root:
Create a hook file
Create a file ending in .pb.js or .pb.ts: touch pb_hooks/main.pb.js
Write your first hook
Add some code to your hook file: onRecordCreate (( e ) => {
console . log ( "Record created:" , e . record . get ( "id" ))
})
Configuration
The JSVM plugin accepts the following configuration options:
Option Type Default Description HooksDirstring pb_data/../pb_hooksDirectory containing JavaScript hooks HooksWatchboolean falseEnable auto-restart on file changes HooksFilesPatternstring ^.*(\\.pb\\.js|\\.pb\\.ts)$Regex pattern for hook files HooksPoolSizeint 0Number of pre-warmed JS runtimes MigrationsDirstring pb_data/../pb_migrationsDirectory for JS migrations OnInitfunction nilCallback 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
Binding Description $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...
Examples
Basic record hook
// 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
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
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
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
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
// 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:
/// < 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():
Install packages
npm install lodash validator
Use in hooks
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" )
}
})
Use async operations carefully
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:
Next steps
Event hooks reference See all available event hooks
Custom routes Learn about custom API endpoints