74 KiB
Server-Side Extending
Impact: HIGH
Extending PocketBase with Go or embedded JavaScript (JSVM) - event hooks, custom routes, transactions, cron jobs, filesystem, migrations, and safe server-side filter binding.
1. Compose Hooks, Transactions, Routing, and Enrich in One Request Flow
Impact: HIGH (Individual rules are atomic; this composite example shows which app instance applies at each layer and how errors propagate)
The atomic rules (ext-hooks-chain, ext-transactions, ext-routing-custom, ext-hooks-record-vs-request, ext-filesystem, ext-filter-binding-server) each teach a single trap. Real extending code touches all of them in the same handler. This rule walks through one complete request flow and annotates which app instance is active at each layer - the single most common source of extending bugs is reaching for the wrong one.
The flow
POST /api/myapp/posts that: authenticates the caller, validates uniqueness with a bound filter, creates a record inside a transaction, uploads a thumbnail through a scoped filesystem, writes an audit log from an OnRecordAfterCreateSuccess hook, and shapes the response (including the realtime broadcast) in OnRecordEnrich.
HTTP request
│
▼
[group middleware] apis.RequireAuth("users") ◄── e.Auth is set after this
│
▼
[route handler] se.App.RunInTransaction(func(txApp) {
│ // ⚠️ inside the block, use ONLY txApp, never se.App or outer `app`
│ FindFirstRecordByFilter(txApp, ...) // bound {:slug}
│ txApp.Save(post) // fires OnRecord*Create / *Request
│ │
│ ▼
│ [OnRecordAfterCreateSuccess hook] ◄── e.App IS txApp here
│ │ (hook fires inside the tx)
│ e.App.Save(auditRecord) → participates in rollback
│ e.Next() → REQUIRED
│ │
│ ▼
│ return to route handler
│ fs := txApp.NewFilesystem()
│ defer fs.Close()
│ post.Set("thumb", file); txApp.Save(post)
│ return nil // commit
│ })
│
▼
[enrich pass] OnRecordEnrich fires ◄── RUNS AFTER the tx committed
│ (also fires for realtime SSE and list responses)
│ e.App is the outer app; tx is already closed
▼
[response serialization] e.JSON(...)
The code
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
g := se.Router.Group("/api/myapp")
g.Bind(apis.RequireAuth("users"))
g.POST("/posts", func(e *core.RequestEvent) error {
// ── Layer 1: route handler ────────────────────────────────────────
// e.App is the top-level app. e.Auth is populated by RequireAuth.
// e.RequestInfo holds headers/body/query.
body := struct {
Slug string `json:"slug"`
Title string `json:"title"`
}{}
if err := e.BindBody(&body); err != nil {
return e.BadRequestError("invalid body", err)
}
var created *core.Record
// ── Layer 2: transaction ──────────────────────────────────────────
txErr := e.App.RunInTransaction(func(txApp core.App) error {
// ⚠️ From here until the closure returns, every DB call MUST go
// through txApp. Capturing e.App or the outer `app` deadlocks
// on the writer lock.
// Bound filter - see ext-filter-binding-server
existing, _ := txApp.FindFirstRecordByFilter(
"posts",
"slug = {:slug}",
dbx.Params{"slug": body.Slug},
)
if existing != nil {
return apis.NewBadRequestError("slug already taken", nil)
}
col, err := txApp.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
post := core.NewRecord(col)
post.Set("slug", body.Slug)
post.Set("title", body.Title)
post.Set("author", e.Auth.Id)
// txApp.Save fires record hooks INSIDE the tx
if err := txApp.Save(post); err != nil {
return err
}
// ── Layer 3: filesystem (scoped to this request) ─────────────
fs, err := txApp.NewFilesystem()
if err != nil {
return err
}
defer fs.Close() // REQUIRED - see ext-filesystem
if uploaded, ok := e.RequestInfo.Body["thumb"].(*filesystem.File); ok {
post.Set("thumb", uploaded)
if err := txApp.Save(post); err != nil {
return err
}
}
created = post
return nil // commit
})
if txErr != nil {
return txErr // framework maps it to a proper HTTP error
}
// ── Layer 5: response (enrich runs automatically) ────────────────
// e.App is the OUTER app again here - the tx has committed.
// OnRecordEnrich will fire during JSON serialization and for any
// realtime subscribers receiving the "create" event.
return e.JSON(http.StatusOK, created)
})
return se.Next()
})
// ── Layer 4: hooks ──────────────────────────────────────────────────────
// These are registered once at startup, NOT inside the route handler.
app.OnRecordAfterCreateSuccess("posts").Bind(&hook.Handler[*core.RecordEvent]{
Id: "audit-post-create",
Func: func(e *core.RecordEvent) error {
// ⚠️ e.App here is txApp when the parent Save happened inside a tx.
// Always use e.App - never a captured outer `app` - so that the
// audit record participates in the same transaction (and the
// same rollback) as the parent Save.
col, err := e.App.FindCollectionByNameOrId("audit")
if err != nil {
return err
}
audit := core.NewRecord(col)
audit.Set("action", "post.create")
audit.Set("record", e.Record.Id)
audit.Set("actor", e.Record.GetString("author"))
if err := e.App.Save(audit); err != nil {
return err // rolls back the whole request
}
return e.Next() // REQUIRED - see ext-hooks-chain
},
})
app.OnRecordEnrich("posts").BindFunc(func(e *core.RecordEnrichEvent) error {
// Runs for:
// - GET /api/collections/posts/records (list)
// - GET /api/collections/posts/records/{id} (view)
// - realtime SSE create/update broadcasts
// - any apis.EnrichRecord call in a custom route
// Does NOT run inside a transaction; e.App is the outer app.
e.Record.Hide("internalNotes")
if e.RequestInfo != nil && e.RequestInfo.Auth != nil {
e.Record.WithCustomData(true)
e.Record.Set("isMine", e.Record.GetString("author") == e.RequestInfo.Auth.Id)
}
return e.Next()
})
The cheat sheet: "which app am I holding?"
| Where you are | Use | Why |
|---|---|---|
Top of a route handler (func(e *core.RequestEvent)) |
e.App |
Framework's top-level app; same object the server started with |
Inside RunInTransaction(func(txApp) { ... }) |
txApp only |
Capturing the outer app deadlocks on the SQLite writer lock |
Inside a record hook fired from a Save inside a tx |
e.App |
The framework has already rebound e.App to txApp for you |
Inside a record hook fired from a non-tx Save |
e.App |
Same identifier, same rules, just points to the top-level app |
Inside OnRecordEnrich |
e.App |
Runs during response serialization, after the tx has committed |
Inside a app.Cron() callback |
captured app / se.App |
Cron has no per-run scoped app; wrap in RunInTransaction if you need atomicity |
| Inside a migration function | the app argument |
m.Register(func(app core.App) error { ... }) - already transactional |
Error propagation in the chain
return errinsideRunInTransaction→ rolls back everything, including any audit records written by hooks that fired from nestedSavecalls.return errfrom a hook handler → propagates back through theSavecall → propagates out of the tx closure → rolls back.- Not calling
e.Next()in a hook → the chain is broken silently. The framework's own post-save work (realtime broadcast, enrich pass, activity log) is skipped but no error is reported. - A panic inside the tx closure is recovered by PocketBase, the tx rolls back, and the panic is converted to a 500 response.
- A panic inside a cron callback is recovered and logged - it does not take down the process.
When NOT to compose this much
This example is realistic but also the ceiling of what should live in a single handler. If you find yourself stacking six concerns in one route, consider splitting the logic into a service function that takes txApp as a parameter and is called by the route. The same function is then reusable from cron jobs, migrations, and tests.
Reference: cross-references ext-hooks-chain.md, ext-transactions.md, ext-routing-custom.md, ext-hooks-record-vs-request.md, ext-filesystem.md, ext-filter-binding-server.md.
2. Schedule Recurring Jobs with the Builtin Cron Scheduler
Impact: MEDIUM (Avoids external schedulers and correctly integrates background tasks with the PocketBase lifecycle)
PocketBase includes a cron scheduler that starts automatically with serve. Register jobs before calling app.Start() (Go) or at the top level of a pb_hooks file (JSVM). Each job runs in its own goroutine and receives a standard cron expression.
Incorrect (external timer, blocking hook, replacing system jobs):
// ❌ Using a raw Go timer instead of the app cron – misses lifecycle management
go func() {
for range time.Tick(2 * time.Minute) {
log.Println("cleanup")
}
}()
// ❌ Blocking inside a hook instead of scheduling
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
for {
time.Sleep(2 * time.Minute)
log.Println("cleanup") // ❌ blocks the hook and never returns se.Next()
}
})
// ❌ Removing all cron jobs wipes PocketBase's own log-cleanup and auto-backup jobs
app.Cron().RemoveAll()
// ❌ JSVM: using setTimeout – not supported in the embedded goja engine
setTimeout(() => console.log("run"), 120_000); // ReferenceError
Correct – Go:
package main
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
// Register before app.Start() so the scheduler knows about the job at launch.
// MustAdd panics on an invalid cron expression (use Add if you prefer an error return).
app.Cron().MustAdd("cleanup-drafts", "0 3 * * *", func() {
// Runs every day at 03:00 UTC in its own goroutine.
// Use app directly here (not e.App) because this is not inside a hook.
records, err := app.FindAllRecords("posts",
core.FilterData("status = 'draft' && created < {:cutoff}"),
)
if err != nil {
app.Logger().Error("cron cleanup-drafts", "err", err)
return
}
for _, r := range records {
if err := app.Delete(r); err != nil {
app.Logger().Error("cron delete", "id", r.Id, "err", err)
}
}
})
// Remove a job by ID (e.g. during a feature flag toggle)
// app.Cron().Remove("cleanup-drafts")
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Correct – JSVM:
// pb_hooks/crons.pb.js
/// <reference path="../pb_data/types.d.ts" />
// Top-level cronAdd() registers the job at hook-load time.
// The handler runs in its own goroutine and has access to $app.
cronAdd("notify-unpublished", "*/30 * * * *", () => {
// Runs every 30 minutes
const records = $app.findAllRecords("posts",
$dbx.hashExp({ status: "draft" })
);
console.log(`Found ${records.length} unpublished posts`);
});
// Remove a registered job by ID (useful in tests or feature toggles)
// cronRemove("notify-unpublished");
Cron expression reference:
┌─── minute (0 - 59)
│ ┌── hour (0 - 23)
│ │ ┌─ day-of-month (1 - 31)
│ │ │ ┌ month (1 - 12)
│ │ │ │ ┌ day-of-week (0 - 6, Sunday = 0)
│ │ │ │ │
* * * * *
Examples:
*/2 * * * * every 2 minutes
0 3 * * * daily at 03:00
0 0 * * 0 weekly on Sunday midnight
@hourly macro equivalent to 0 * * * *
Key rules:
- System jobs use the
__pb*__ID prefix (e.g.__pbLogsCleanup__). Never callRemoveAll()or use that prefix for your own jobs. - All registered cron jobs are visible and can be manually triggered from Dashboard > Settings > Crons.
- JSVM handlers have access to
$appbut not to outer-scope variables (see JSVM scope rule). - Go jobs can use
appdirectly (note.App) because they run outside the hook/transaction context.
Reference: Go – Jobs scheduling | JS – Jobs scheduling
3. Always Close the Filesystem Handle Returned by NewFilesystem
Impact: HIGH (Leaked filesystem clients keep S3 connections and file descriptors open until the process exits)
app.NewFilesystem() (Go) and $app.newFilesystem() (JS) return a filesystem client backed by either the local disk or S3, depending on the app settings. The caller owns the handle and must close it - there is no finalizer and no automatic pooling. Leaking handles leaks TCP connections to S3 and file descriptors on disk, and eventually the server will stop accepting uploads.
PocketBase also ships a second client: app.NewBackupsFilesystem() for the backups bucket/directory, with the same ownership rules.
Incorrect (no close, raw bytes buffered in memory):
// ❌ Forgets to close fs - connection leaks
func downloadAvatar(app core.App, key string) ([]byte, error) {
fs, err := app.NewFilesystem()
if err != nil {
return nil, err
}
// ❌ no defer fs.Close()
// ❌ GetFile loads the whole file into a reader; reading it all into a
// byte slice defeats streaming for large files
r, err := fs.GetFile(key)
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
Correct (defer Close, stream to the HTTP response):
func serveAvatar(app core.App, key string) echo.HandlerFunc {
return func(e *core.RequestEvent) error {
fs, err := app.NewFilesystem()
if err != nil {
return e.InternalServerError("filesystem init failed", err)
}
defer fs.Close() // REQUIRED
// Serve directly from the filesystem - handles ranges, content-type,
// and the X-Accel-Redirect / X-Sendfile headers when available
return fs.Serve(e.Response, e.Request, key, "avatar.jpg")
}
}
// Uploading a local file to the PocketBase-managed filesystem
func importAvatar(app core.App, record *core.Record, path string) error {
f, err := filesystem.NewFileFromPath(path)
if err != nil {
return err
}
record.Set("avatar", f) // assignment + app.Save() persist it
return app.Save(record)
}
// JSVM - file factories live on the $filesystem global
const file1 = $filesystem.fileFromPath("/tmp/import.jpg");
const file2 = $filesystem.fileFromBytes(new Uint8Array([0xff, 0xd8]), "logo.jpg");
const file3 = $filesystem.fileFromURL("https://example.com/a.jpg");
// Assigning to a record field triggers upload on save
record.set("avatar", file1);
$app.save(record);
// Low-level client - MUST be closed
const fs = $app.newFilesystem();
try {
const list = fs.list("thumbs/");
for (const obj of list) {
console.log(obj.key, obj.size);
}
} finally {
fs.close(); // REQUIRED
}
Rules:
defer fs.Close()immediately after a successfulNewFilesystem()/NewBackupsFilesystem()call (Go). In JS, wrap intry { ... } finally { fs.close() }.- Prefer the high-level record-field API (
record.Set("field", file)+app.Save) over directfs.Uploadcalls - it handles thumbs regeneration, orphan cleanup, and hook integration. - File factory functions (
filesystem.NewFileFromPath,NewFileFromBytes,NewFileFromURL/ JS$filesystem.fileFromPath|Bytes|URL) capture their input; they do not stream until save. fileFromURLperforms an HTTP GET and loads the body into memory - not appropriate for large files.- Do not share a single long-lived
fsacross unrelated requests; the object is cheap to create per request.
Reference: Go Filesystem · JS Filesystem
4. Bind User Input in Server-Side Filters with {:placeholder} Params
Impact: CRITICAL (String-concatenating user input into filter expressions is a direct injection vulnerability)
Server-side helpers like FindFirstRecordByFilter, FindRecordsByFilter, and dbx.NewExp accept a filter string that supports {:name} placeholders. Never concatenate user input into the filter - PocketBase's filter parser has its own syntax that is sensitive to quoting, and concatenation allows an attacker to alter the query (same class of bug as SQL injection).
Incorrect (string interpolation - filter injection):
// ❌ attacker sets email to: x' || 1=1 || email='
// resulting filter bypasses the intended match entirely
email := e.Request.URL.Query().Get("email")
record, err := app.FindFirstRecordByFilter(
"users",
"email = '"+email+"' && verified = true", // ❌
)
// JSVM - same class of bug
const email = e.request.url.query().get("email");
const record = $app.findFirstRecordByFilter(
"users",
`email = '${email}' && verified = true`, // ❌
);
Correct (named placeholders + params map):
import "github.com/pocketbase/dbx"
email := e.Request.URL.Query().Get("email")
record, err := app.FindFirstRecordByFilter(
"users",
"email = {:email} && verified = true",
dbx.Params{"email": email}, // values are quoted/escaped by the framework
)
if err != nil {
return e.NotFoundError("user not found", err)
}
// Paginated variant: FindRecordsByFilter(collection, filter, sort, limit, offset, params...)
recs, err := app.FindRecordsByFilter(
"posts",
"author = {:author} && status = {:status}",
"-created",
20, 0,
dbx.Params{"author": e.Auth.Id, "status": "published"},
)
// JSVM - second argument after the filter is the params object
const record = $app.findFirstRecordByFilter(
"users",
"email = {:email} && verified = true",
{ email: email },
);
const recs = $app.findRecordsByFilter(
"posts",
"author = {:author} && status = {:status}",
"-created", 20, 0,
{ author: e.auth.id, status: "published" },
);
Rules:
- Placeholder syntax is
{:name}inside the filter string, and the value is supplied viadbx.Params{"name": value}(Go) or a plain object (JS). - The same applies to
dbx.NewExp("LOWER(email) = {:email}", dbx.Params{"email": email})when writing rawdbxexpressions. - Passing a
types.DateTime/DateTimevalue binds it correctly - do not stringify dates manually. nil/nullbinds as SQL NULL; usefield = nullorfield != nullin the filter expression.- The filter grammar is the same as used by collection API rules - consult Filter Syntax for operators.
Reference: Go database - FindRecordsByFilter · JS database - findRecordsByFilter
5. Use DBConnect Only When You Need a Custom SQLite Driver
Impact: MEDIUM (Incorrect driver setup breaks both data.db and auxiliary.db, or introduces unnecessary CGO)
PocketBase ships with the pure-Go modernc.org/sqlite driver (no CGO required). Only reach for a custom driver when you specifically need SQLite extensions like ICU, FTS5, or spatialite that the default driver doesn't expose. DBConnect is called twice — once for pb_data/data.db and once for pb_data/auxiliary.db — so driver registration and PRAGMAs must be idempotent.
Incorrect (unnecessary custom driver, mismatched builder, CGO without justification):
// ❌ Adding a CGO dependency with no need for extensions
import _ "github.com/mattn/go-sqlite3"
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
// ❌ "sqlite3" builder name used but "pb_sqlite3" driver was registered —
// or vice versa — causing "unknown driver" / broken query generation
return dbx.Open("sqlite3", dbPath)
},
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Correct (mattn/go-sqlite3 with CGO — proper PRAGMA init hook and builder map entry):
package main
import (
"database/sql"
"log"
"github.com/mattn/go-sqlite3"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
)
func init() {
// Use a unique driver name to avoid conflicts with other packages.
// sql.Register panics if called twice with the same name, so put it in init().
sql.Register("pb_sqlite3", &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error {
_, err := conn.Exec(`
PRAGMA busy_timeout = 10000;
PRAGMA journal_mode = WAL;
PRAGMA journal_size_limit = 200000000;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -32000;
`, nil)
return err
},
})
// Mirror the sqlite3 query builder so PocketBase generates correct SQL
dbx.BuilderFuncMap["pb_sqlite3"] = dbx.BuilderFuncMap["sqlite3"]
}
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("pb_sqlite3", dbPath)
},
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Correct (ncruces/go-sqlite3 — no CGO, PRAGMAs via DSN query string):
package main
import (
"log"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
)
func main() {
const pragmas = "?_pragma=busy_timeout(10000)" +
"&_pragma=journal_mode(WAL)" +
"&_pragma=journal_size_limit(200000000)" +
"&_pragma=synchronous(NORMAL)" +
"&_pragma=foreign_keys(ON)" +
"&_pragma=temp_store(MEMORY)" +
"&_pragma=cache_size(-32000)"
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
return dbx.Open("sqlite3", "file:"+dbPath+pragmas)
},
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Conditional custom driver with default fallback:
app := pocketbase.NewWithConfig(pocketbase.Config{
DBConnect: func(dbPath string) (*dbx.DB, error) {
// Use custom driver only for the main data file; fall back for auxiliary
if strings.HasSuffix(dbPath, "data.db") {
return dbx.Open("pb_sqlite3", dbPath)
}
return core.DefaultDBConnect(dbPath)
},
})
Decision guide:
| Need | Driver |
|---|---|
| Default (no extensions) | Built-in modernc.org/sqlite — no DBConnect config needed |
| FTS5, ICU, spatialite | mattn/go-sqlite3 (CGO) or ncruces/go-sqlite3 (WASM, no CGO) |
| Reduce binary size | go build -tags no_default_driver to exclude the default driver (~4 MB saved) |
| Conditional fallback | Call core.DefaultDBConnect(dbPath) inside your DBConnect function |
Reference: Extend with Go - Custom SQLite driver
6. Version Your Schema with Go Migrations
Impact: HIGH (Guarantees repeatable, transactional schema evolution and eliminates manual dashboard changes in production)
PocketBase ships with a migratecmd plugin that generates versioned .go migration files, applies them automatically on serve, and lets you roll back with migrate down. Because the files are compiled into your binary, no extra migration tool is needed.
Incorrect (one-off SQL or dashboard changes in production):
// ❌ Running raw SQL directly at startup without a migration file –
// the change is applied every restart and has no rollback path.
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
_, err := app.DB().NewQuery(
"ALTER TABLE posts ADD COLUMN summary TEXT DEFAULT ''",
).Execute()
return err
})
// ❌ Forgetting to import the migrations package means
// registered migrations are never executed.
package main
import (
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
// _ "myapp/migrations" ← omitted: migrations never run
)
Correct (register migratecmd, import migrations package):
// main.go
package main
import (
"log"
"os"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/pocketbase/pocketbase/tools/osutils"
// Import side-effects only; this registers all init() migrations.
_ "myapp/migrations"
)
func main() {
app := pocketbase.New()
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
// Automigrate generates a new .go file whenever you make
// collection changes in the Dashboard (dev-only).
Automigrate: osutils.IsProbablyGoRun(),
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Create and write a migration:
# Create a blank migration file in ./migrations/
go run . migrate create "add_summary_to_posts"
// migrations/1687801090_add_summary_to_posts.go
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// app is a transactional App instance – safe to use directly.
collection, err := app.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
collection.Fields.Add(&core.TextField{
Name: "summary",
Required: false,
})
return app.Save(collection)
}, func(app core.App) error {
// Optional rollback
collection, err := app.FindCollectionByNameOrId("posts")
if err != nil {
return err
}
collection.Fields.RemoveByName("summary")
return app.Save(collection)
})
}
Snapshot all collections (useful for a fresh repo):
# Generates a migration file that recreates your current schema from scratch.
go run . migrate collections
Clean up dev migration history:
# Remove _migrations table entries that have no matching .go file.
# Run after squashing or deleting intermediate dev migration files.
go run . migrate history-sync
Apply / roll back manually:
go run . migrate up # apply all unapplied migrations
go run . migrate down 1 # revert the last applied migration
Key details:
- Migration functions receive a transactional
core.App– treat it as the database source of truth. Never use the outerappvariable inside migration callbacks. - New unapplied migrations run automatically on every
servestart – no manual step in production. Automigrate: osutils.IsProbablyGoRun()limits auto-generation togo run(development) and prevents accidental file creation in production binaries.- Prefer the collection API (
app.Save(collection)) over raw SQLALTER TABLEso PocketBase's internal schema cache stays consistent. - Commit all generated
.gofiles to version control; do not commitpb_data/.
Reference: Extend with Go – Migrations
7. Set Up a Go-Extended PocketBase Application
Impact: HIGH (Foundation for all custom Go business logic, hooks, and routing)
When extending PocketBase as a Go framework (v0.36+), the entry point is a small main.go that creates the app, registers hooks on OnServe(), and calls app.Start(). Avoid reaching for a global app variable inside hook handlers - use e.App instead so code works inside transactions.
Incorrect (global app reuse, no OnServe hook, bare http.Handler):
package main
import (
"log"
"net/http"
"github.com/pocketbase/pocketbase"
)
var app = pocketbase.New() // global reference used inside handlers
func main() {
// Routes registered directly via net/http - bypasses PocketBase's router,
// middleware chain, auth, rate limiter and body limit
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("hello"))
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Correct (register routes inside OnServe, use e.App in handlers):
package main
import (
"log"
"net/http"
"os"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Serve static assets from ./pb_public (if present)
se.Router.GET("/{path...}", apis.Static(os.DirFS("./pb_public"), false))
// Custom API route - namespaced under /api/{yourapp}/ to avoid
// colliding with built-in /api/collections, /api/realtime, etc.
se.Router.GET("/api/myapp/hello/{name}", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{
"message": "hello " + e.Request.PathValue("name"),
})
}).Bind(apis.RequireAuth())
return se.Next()
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Project bootstrap:
go mod init myapp
go mod tidy
go run . serve # development
go build && ./myapp serve # production (statically linked binary)
Key details:
- Requires Go 1.25.0+ (PocketBase v0.36.7+ bumped the minimum to Go 1.25.0).
- PocketBase ships with the pure-Go
modernc.org/sqlitedriver - no CGO required by default. - If you need FTS5, ICU, or a custom SQLite build, pass
core.DBConnectinpocketbase.NewWithConfig(...)- it is called twice (once forpb_data/data.db, once forpb_data/auxiliary.db). - Inside hooks, prefer
e.Appover a captured parent-scopeapp- the hook may run inside a transaction and the parentappwould deadlock.
Reference: Extend with Go - Overview
8. Always Call e.Next() and Use e.App Inside Hook Handlers
Impact: CRITICAL (Forgetting e.Next() silently breaks the execution chain; reusing parent-scope app causes deadlocks)
Every PocketBase event hook handler is part of an execution chain. If the handler does not call e.Next() (Go) or e.next() (JS), the remaining handlers and the core framework action are skipped silently. Also, hooks may run inside a DB transaction - any database call made through a captured parent-scope app/$app instead of the event's own e.App/e.app will deadlock against the transaction.
Incorrect (missing Next, captured parent-scope app, global mutex):
var mu sync.Mutex // ❌ global lock invoked recursively by cascade hooks = deadlock
app := pocketbase.New()
app.OnRecordAfterCreateSuccess("articles").BindFunc(func(e *core.RecordEvent) error {
mu.Lock()
defer mu.Unlock()
// ❌ uses outer `app`, not `e.App` - deadlocks when the hook fires
// inside a transaction, because the outer app is blocked on the
// transaction's write lock
_, err := app.FindRecordById("audit", e.Record.Id)
if err != nil {
return err
}
return nil // ❌ forgot e.Next() - framework never persists the record
})
// JSVM
onRecordAfterCreateSuccess((e) => {
// ❌ no e.next() = downstream hooks and response serialization skipped
console.log("created", e.record.id);
}, "articles");
Correct (call Next, use e.App, attach an Id for later unbinding):
app := pocketbase.New()
app.OnRecordAfterCreateSuccess("articles").Bind(&hook.Handler[*core.RecordEvent]{
Id: "audit-article-create",
Priority: 10, // higher = later; default 0 = order of registration
Func: func(e *core.RecordEvent) error {
// Always use e.App - it is the transactional app when inside a tx
audit := core.NewRecord(/* ... */)
audit.Set("record", e.Record.Id)
if err := e.App.Save(audit); err != nil {
return err
}
return e.Next() // REQUIRED
},
})
// Later: app.OnRecordAfterCreateSuccess("articles").Unbind("audit-article-create")
// JSVM - e.app is the transactional app instance
onRecordAfterCreateSuccess((e) => {
const audit = new Record($app.findCollectionByNameOrId("audit"));
audit.set("record", e.record.id);
e.app.save(audit);
e.next(); // REQUIRED
}, "articles");
Rules of the execution chain:
Bind(handler)vsBindFunc(func):Bindlets you setId(forUnbind) andPriority;BindFuncauto-generates both.- Priority defaults to
0= order of source registration. Lower numbers run first, negative priorities run before defaults (the built-in middlewares use priorities like-1010,-1000,-990). - Never hold a global mutex across
e.Next()- cascade-delete and nested saves can re-enter the same hook and deadlock. Unbind(id)removes a specific handler;UnbindAll()also removes system handlers, so only call it if you really mean to replace the default behavior.Trigger(event, ...)is almost never needed in user code.
Reference: Go Event hooks · JS Event hooks
9. Pick the Right Record Hook - Model vs Request vs Enrich
Impact: HIGH (Wrong hook = missing request context, double-fired logic, or leaked fields in realtime events)
PocketBase v0.23+ splits record hooks into three families. Using the wrong one is the #1 source of "my hook doesn't fire" and "my hidden field still shows up in realtime events" bugs.
| Family | Examples | Fires for | Has request context? |
|---|---|---|---|
| Model hooks | OnRecordCreate, OnRecordAfterCreateSuccess, OnRecordValidate |
Any save path - Web API and cron jobs, custom commands, migrations, calls from other hooks | No - e.Record, e.App, no e.RequestInfo |
| Request hooks | OnRecordCreateRequest, OnRecordsListRequest, OnRecordViewRequest |
Only the built-in Web API endpoints | Yes - e.RequestInfo, e.Auth, HTTP headers/body |
| Enrich hook | OnRecordEnrich |
Every response serialization, including realtime SSE events and apis.enrichRecord |
Yes, via e.RequestInfo |
Incorrect (hiding a field in the request hook - leaks in realtime):
// ❌ Only runs for GET /api/collections/users/records/{id}.
// Realtime SSE subscribers still receive the "role" field.
app.OnRecordViewRequest("users").BindFunc(func(e *core.RecordRequestEvent) error {
e.Record.Hide("role")
return e.Next()
})
Correct (use OnRecordEnrich so realtime and list responses also hide the field):
app.OnRecordEnrich("users").BindFunc(func(e *core.RecordEnrichEvent) error {
e.Record.Hide("role")
// Add a computed field only for authenticated users
if e.RequestInfo.Auth != nil {
e.Record.WithCustomData(true) // required to attach non-schema data
e.Record.Set("isOwner", e.Record.Id == e.RequestInfo.Auth.Id)
}
return e.Next()
})
// JSVM
onRecordEnrich((e) => {
e.record.hide("role");
if (e.requestInfo.auth?.collection()?.name === "users") {
e.record.withCustomData(true);
e.record.set("computedScore",
e.record.get("score") * e.requestInfo.auth.get("base"));
}
e.next();
}, "users");
Selection guide:
- Need to mutate the record before any save (API, cron, migration, nested hook)? →
OnRecordCreate/OnRecordUpdate(pre-save) orOnRecord*Success(post-save). - Need access to HTTP headers, query params, or the authenticated client? →
OnRecord*Request. - Need to hide fields, redact values, or attach computed props on responses including realtime? →
OnRecordEnrich- this is the safest default for response shaping. - Need to validate before save? →
OnRecordValidate(proxy overOnModelValidate).
Reference: Go Record request hooks · JS Record model hooks
10. Write JSVM Migrations as pb_migrations/*.js Files
Impact: HIGH (JSVM migrations look different from Go ones; missing the timestamp prefix or the down-callback silently breaks replay)
JSVM migrations live in pb_migrations/ next to the executable. Unlike Go migrations (which use init() + m.Register(...) inside a package imported from main.go), JSVM migrations are auto-discovered by filename and call the global migrate() function with an up callback and an optional down callback. --automigrate is on by default in v0.36+, so admin-UI changes generate these files for you; you also write them by hand for data migrations, seed data, and index changes that the UI can't express.
Incorrect (wrong filename format, missing down, raw SQL without cache invalidation):
// pb_migrations/add_audit.js ❌ missing <unix>_ prefix - never runs
migrate((app) => {
// ❌ Raw ALTER TABLE leaves PocketBase's internal collection cache stale
app.db().newQuery(
"ALTER TABLE posts ADD COLUMN summary TEXT DEFAULT ''"
).execute();
});
// ❌ No down callback - `migrate down` cannot revert this in dev
Correct (timestamped filename, collection API, both up and down):
// pb_migrations/1712500000_add_audit_collection.js
/// <reference path="../pb_data/types.d.ts" />
migrate(
// UP - runs on `serve` / `migrate up`
(app) => {
const collection = new Collection({
type: "base",
name: "audit",
fields: [
{ name: "action", type: "text", required: true },
{ name: "actor", type: "relation", collectionId: "_pb_users_auth_", cascadeDelete: false },
{ name: "meta", type: "json" },
{ name: "created", type: "autodate", onCreate: true },
],
indexes: [
"CREATE INDEX idx_audit_actor ON audit (actor)",
"CREATE INDEX idx_audit_created ON audit (created)",
],
});
app.save(collection);
},
// DOWN - runs on `migrate down N`
(app) => {
const collection = app.findCollectionByNameOrId("audit");
app.delete(collection);
},
);
Seed data migration (common pattern):
// pb_migrations/1712500100_seed_default_tags.js
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const tags = app.findCollectionByNameOrId("tags");
for (const name of ["urgent", "bug", "feature", "docs"]) {
const r = new Record(tags);
r.set("name", name);
app.save(r); // `app` here is the transactional app - all or nothing
}
},
(app) => {
const tags = app.findCollectionByNameOrId("tags");
for (const name of ["urgent", "bug", "feature", "docs"]) {
const r = app.findFirstRecordByFilter(
"tags",
"name = {:name}",
{ name },
);
if (r) app.delete(r);
}
},
);
CLI commands (same as Go migrations):
./pocketbase migrate create "add_audit_collection" # templated blank file
./pocketbase migrate up # apply pending
./pocketbase migrate down 1 # revert last
./pocketbase migrate history-sync # reconcile _migrations table
Rules:
- Filename format:
<unix_timestamp>_<description>.js. The timestamp sets ordering. Never renumber a committed file. - The
appargument is transactional: every migration runs inside its own transaction. Throw to roll back. Do not capture$appfrom the outer scope - use theappparameter so the work participates in the tx. - Use the collection API (
new Collection,app.save(collection)), not rawALTER TABLE. Raw SQL leaves PocketBase's in-memory schema cache stale until the next restart. - Always write the down callback in development. In production, down migrations are rare but the callback is what makes
migrate down 1work during emergency rollbacks. - Do not import from other files - goja has no ES modules, and at migration time the
pb_hooksloader has not necessarily run. Keep each migration self-contained. - Commit
pb_migrations/to version control. Never commitpb_data/. - Conflicting with Go migrations: you can run either Go or JS migrations, not a mix of both in the same project. JSVM migrations are enabled by default; Go migrations require
migratecmd.MustRegister(...)inmain.go.
Reference: Extend with JavaScript - Migrations
11. Set Up JSVM (pb_hooks) for Server-Side JavaScript
Impact: HIGH (Correct setup unlocks hot-reload, type-completion, and the full JSVM API)
The prebuilt PocketBase executable embeds an ES5 JavaScript engine (goja). Drop *.pb.js files into a pb_hooks directory next to the executable and they load automatically at startup. Files are loaded in filename sort order, and on UNIX platforms the process auto-reloads when any pb_hooks file changes.
Incorrect (TypeScript without transpile, wrong filename, missing types reference):
// pb_hooks/main.ts ❌ PocketBase loads ONLY *.pb.js - a .ts file is ignored
import { something } from "./lib"; // ❌ ES modules not supported in goja
routerAdd("GET", "/hello", (e) => e.json(200, { ok: true }));
// pb_hooks/hooks.js ❌ wrong extension - must be *.pb.js
// No /// reference -> editor shows every call as "any"
onRecordAfterUpdateSuccess((e) => {
console.log(e.record.get("email"));
// Missing e.next() - stops the execution chain silently
}, "users");
Correct (valid filename, types reference, e.next() called):
// pb_hooks/main.pb.js
/// <reference path="../pb_data/types.d.ts" />
// Hooks defined earlier in the filename sort order run first.
// Use prefixes like "01_", "10_", "99_" if order matters.
routerAdd("GET", "/api/myapp/hello/{name}", (e) => {
const name = e.request.pathValue("name");
return e.json(200, { message: "Hello " + name });
});
onRecordAfterUpdateSuccess((e) => {
console.log("user updated:", e.record.get("email"));
e.next(); // REQUIRED - otherwise the execution chain is broken
}, "users");
Key details:
- JS method names are camelCase versions of their Go equivalents (
FindRecordById→$app.findRecordById). - Errors are thrown as regular JS exceptions, not returned as values.
- Global objects:
$app(the app),$apis(routing helpers/middlewares),$os(OS primitives),$security(JWT, random strings, AES),$filesystem(file factories),$dbx(query builder),$mails(email helpers),__hooks(absolute path topb_hooks). pb_data/types.d.tsis regenerated automatically - commit the triple-slash reference but not the file itself if you prefer.- Auto-reload on file change works on UNIX only. On Windows, restart the process manually.
Reference: Extend with JavaScript - Overview
12. Load Shared Code with CommonJS require() in pb_hooks
Impact: MEDIUM (Correct module usage prevents require() failures, race conditions, and ESM import errors)
The embedded JSVM (goja) supports only CommonJS (require()). ES module import syntax is not supported without pre-bundling. Modules use a shared registry — they are evaluated once and cached, so avoid mutable module-level state to prevent race conditions across concurrent requests.
Incorrect (ESM imports, mutable shared state, Node.js APIs):
// ❌ ESM import syntax — not supported by goja
import { sendEmail } from "./utils.js";
// ❌ Node.js APIs don't exist in the JSVM sandbox
const fs = require("fs");
fs.writeFileSync("output.txt", "hello"); // ReferenceError
// ❌ Mutable module-level state is shared across concurrent requests
// pb_hooks/counter.js
let requestCount = 0;
module.exports = { increment: () => ++requestCount }; // race condition
Correct (CommonJS require, stateless helpers, JSVM bindings for OS/file ops):
// pb_hooks/utils.js — stateless helper module
module.exports = {
formatDate: (d) => new Date(d).toISOString().slice(0, 10),
validateEmail: (addr) => /^[^@]+@[^@]+\.[^@]+$/.test(addr),
};
// pb_hooks/main.pb.js
/// <reference path="../pb_data/types.d.ts" />
onRecordAfterCreateSuccess((e) => {
const utils = require(`${__hooks}/utils.js`);
const date = utils.formatDate(e.record.get("created"));
console.log("Record created on:", date);
e.next();
}, "posts");
// Use $os.* for file system operations (not Node.js fs)
routerAdd("GET", "/api/myapp/read-config", (e) => {
const raw = $os.readFile(`${__hooks}/config.json`);
const cfg = JSON.parse(raw);
return e.json(200, { name: cfg.appName });
});
// Use $filesystem.s3(...) or $filesystem.local(...) for storage (v0.36.4+)
routerAdd("POST", "/api/myapp/upload", (e) => {
const bucket = $filesystem.s3({
endpoint: "s3.amazonaws.com",
bucket: "my-bucket",
region: "us-east-1",
accessKey: $app.settings().s3.accessKey,
secret: $app.settings().s3.secret,
});
// ... use bucket to store/retrieve files
return e.json(200, { ok: true });
}, $apis.requireAuth());
Using third-party CJS packages:
// node_modules/ is searched automatically alongside __hooks.
// Install packages with npm next to the pb_hooks directory, then require by name.
onBootstrap((e) => {
e.next();
// Only CJS-compatible packages work without bundling
const slugify = require("slugify");
console.log(slugify("Hello World")); // "Hello-World"
});
Using ESM-only packages (bundle to CJS first):
# Bundle an ESM package to CJS with rollup before committing it to pb_hooks
npx rollup node_modules/some-esm-pkg/index.js \
--file pb_hooks/vendor/some-esm-pkg.js \
--format cjs
onBootstrap((e) => {
e.next();
const pkg = require(`${__hooks}/vendor/some-esm-pkg.js`);
});
JSVM engine limitations:
- No
setTimeout/setInterval— no async scheduling inside handlers. - No Node.js APIs (
fs,Buffer,process, etc.) — use$os.*and$filesystem.*JSVM bindings instead. - No browser APIs (
fetch,window,localStorage) — use$app.newHttpClient()for outbound HTTP requests. - ES6 is mostly supported but not fully spec-compliant (goja engine).
- The prebuilt PocketBase executable starts a pool of 15 JS runtimes by default; adjust with
--hooksPool=Nfor high-concurrency workloads (more runtimes = more memory, better throughput). nullString(),nullInt(),nullFloat(),nullBool(),nullArray(),nullObject()helpers are available (v0.35.0+) for scanning nullable DB columns safely.
Reference: Extend with JavaScript - Loading modules
13. Avoid Capturing Variables Outside JSVM Handler Scope
Impact: HIGH (Variables defined outside a handler are undefined at runtime due to handler serialization)
Each JSVM handler (hook, route, middleware) is serialized and executed as an isolated program. Variables or functions declared at the module/file scope are NOT accessible inside handler bodies. This is the most common source of undefined errors in pb_hooks code.
Incorrect (accessing outer-scope variable inside handler):
// pb_hooks/main.pb.js
const APP_NAME = "myapp"; // ❌ will be undefined inside handlers
onBootstrap((e) => {
e.next();
console.log(APP_NAME); // ❌ undefined — APP_NAME is not in handler scope
});
// ❌ Even $app references captured here may not work as expected
const helper = (id) => $app.findRecordById("posts", id);
onRecordAfterCreateSuccess((e) => {
helper(e.record.id); // ❌ helper is undefined inside the handler
}, "posts");
Correct (move shared state into a required module, or use $app/e.app directly):
// pb_hooks/config.js — stateless CommonJS module
module.exports = {
APP_NAME: "myapp",
MAX_RETRIES: 3,
};
// pb_hooks/main.pb.js
/// <reference path="../pb_data/types.d.ts" />
onBootstrap((e) => {
e.next();
// Load the shared module INSIDE the handler
const config = require(`${__hooks}/config.js`);
console.log(config.APP_NAME); // ✅ "myapp"
});
routerAdd("GET", "/api/myapp/status", (e) => {
const config = require(`${__hooks}/config.js`);
return e.json(200, { app: config.APP_NAME });
});
onRecordAfterCreateSuccess((e) => {
// Access the app directly via e.app inside the handler
const post = e.app.findRecordById("posts", e.record.id);
e.next();
}, "posts");
Key rules:
- Every handler body is serialized to a string and executed in its own isolated goja runtime context. There is no shared global state between handlers at runtime.
require()loads modules from a shared registry — modules are evaluated once and cached. Keep module-level code stateless; avoid mutable module exports to prevent data races under concurrent requests.__hooksis always available inside handlers and resolves to the absolute path of thepb_hooksdirectory.- Error stack trace line numbers may not be accurate because of the handler serialization — log meaningful context manually when debugging.
- Workaround for simple constants: move them to a
config.jsmodule andrequire()it inside each handler that needs it.
Reference: Extend with JavaScript - Handlers scope
14. Send Email via app.NewMailClient, Never the Default example.com Sender
Impact: HIGH (Default sender is no-reply@example.com; shipping it bounces every email and damages your SMTP reputation)
PocketBase ships with a mailer accessible through app.NewMailClient() (Go) or $app.newMailClient() (JS). It reads the SMTP settings configured in Admin UI → Settings → Mail settings, or falls back to a local sendmail-like client if SMTP is not configured. Two things bite people: (1) the default Meta.senderAddress is no-reply@example.com - shipping with that bounces every email and poisons your sender reputation; (2) there is no connection pooling, so long-lived mail client handles are not safe to share across requests - create one per send.
Incorrect (default sender, shared client, no error handling):
// ❌ Default sender is example.com, and this mailer instance is captured
// for the process lifetime - SMTP connections go stale
var mailer = app.NewMailClient()
app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
msg := &mailer.Message{
From: mail.Address{Address: "no-reply@example.com"}, // ❌
To: []mail.Address{{Address: e.Record.GetString("email")}},
Subject: "Order confirmed",
HTML: "<p>Thanks</p>",
}
mailer.Send(msg) // ❌ error swallowed
return e.Next()
})
Correct (sender from settings, per-send client, explicit error path):
import (
"net/mail"
pbmail "github.com/pocketbase/pocketbase/tools/mailer"
)
app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
// IMPORTANT: resolve the sender from settings at send-time, not at
// startup - an admin can change it live from the UI
meta := e.App.Settings().Meta
from := mail.Address{
Name: meta.SenderName,
Address: meta.SenderAddress,
}
msg := &pbmail.Message{
From: from,
To: []mail.Address{{Address: e.Record.GetString("email")}},
Subject: "Order confirmed",
HTML: renderOrderEmail(e.Record), // your template function
}
// Create the client per send - avoids stale TCP sessions
if err := e.App.NewMailClient().Send(msg); err != nil {
e.App.Logger().Error("order email send failed",
"err", err,
"recordId", e.Record.Id,
)
// Do NOT return the error - a failed email should not roll back the order
}
return e.Next()
})
// JSVM - $mails global exposes message factories
onRecordAfterCreateSuccess((e) => {
const meta = $app.settings().meta;
const message = new MailerMessage({
from: {
address: meta.senderAddress,
name: meta.senderName,
},
to: [{ address: e.record.get("email") }],
subject: "Order confirmed",
html: `<p>Thanks for order ${e.record.id}</p>`,
});
try {
$app.newMailClient().send(message);
} catch (err) {
$app.logger().error("order email send failed", "err", err, "id", e.record.id);
// swallow - do not rollback the order
}
e.next();
}, "orders");
Templated emails via the built-in verification/reset templates:
// PocketBase has baked-in templates for verification, password reset, and
// email change. Trigger them via apis.*Request helpers rather than building
// your own message:
// apis.RecordRequestPasswordReset(app, authRecord)
// apis.RecordRequestVerification(app, authRecord)
// apis.RecordRequestEmailChange(app, authRecord, newEmail)
//
// These use the templates configured in Admin UI → Settings → Mail templates.
Rules:
- Always change
Meta.SenderAddressbefore shipping. In development, use Mailpit or MailHog; in production, use a verified domain that matches your SPF/DKIM records. - Resolve the sender from
app.Settings().Metaat send-time, not at startup. Settings are mutable from the admin UI. - Create the client per send (
app.NewMailClient()/$app.newMailClient()). It is cheap - it re-reads the SMTP settings each time, so config changes take effect without a restart. - Never return a send error from a hook unless the user's action genuinely depends on the email going out. Email failure is common (transient SMTP, address typo) and should not roll back a business transaction.
- Log failures with context (record id, recipient domain) so you can grep them later. PocketBase does not retry failed sends.
- For bulk sending, queue it. The mailer is synchronous - looping
Send()over 10k records blocks the request. Push to a cron-drained queue collection instead. - Template rendering: Go users should use
html/template; JS users can use template literals or pull in a tiny template lib. PocketBase itself only renders templates for its baked-in flows.
Reference: Go Mailer · JS Mailer
15. Register Custom Routes Safely with Built-in Middlewares
Impact: HIGH (Protects custom endpoints with auth, avoids /api path collisions, inherits rate limiting)
PocketBase routing is built on top of net/http.ServeMux. Custom routes are registered inside the OnServe() hook (Go) or via routerAdd() / routerUse() (JSVM). Always namespace custom routes under /api/{yourapp}/... to avoid colliding with built-in endpoints, and attach apis.RequireAuth() / $apis.requireAuth() (or stricter) to anything that is not meant to be public.
Incorrect (path collision, no auth, raw ResponseWriter):
// ❌ "/api/records" collides with /api/collections/{name}/records built-in
se.Router.POST("/api/records", func(e *core.RequestEvent) error {
// ❌ no auth check - anyone can call this
// ❌ returns raw text; no content-type
e.Response.Write([]byte("ok"))
return nil
})
Correct (namespaced, authenticated, group-scoped middleware):
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// Group everything under /api/myapp/ and require auth for the entire group
g := se.Router.Group("/api/myapp")
g.Bind(apis.RequireAuth()) // authenticated users only
g.Bind(apis.Gzip()) // compress responses
g.Bind(apis.BodyLimit(10 << 20)) // per-route override of default 32MB limit
g.GET("/profile", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{
"id": e.Auth.Id,
"email": e.Auth.GetString("email"),
})
})
// Superuser-only admin endpoint
g.POST("/admin/rebuild-index", func(e *core.RequestEvent) error {
// ... do the work
return e.JSON(http.StatusOK, map[string]bool{"ok": true})
}).Bind(apis.RequireSuperuserAuth())
// Resource the owner (or a superuser) can access
g.GET("/users/{id}/private", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"private": "data"})
}).Bind(apis.RequireSuperuserOrOwnerAuth("id"))
return se.Next()
})
// JSVM
routerAdd("GET", "/api/myapp/profile", (e) => {
return e.json(200, {
id: e.auth.id,
email: e.auth.getString("email"),
});
}, $apis.requireAuth());
routerAdd("POST", "/api/myapp/admin/rebuild-index", (e) => {
return e.json(200, { ok: true });
}, $apis.requireSuperuserAuth());
Built-in middlewares (Go: apis.*, JS: $apis.*):
| Middleware | Use |
|---|---|
RequireGuestOnly() |
Reject authenticated clients (e.g. public signup forms) |
RequireAuth(...collections) |
Require any auth record; optionally restrict to specific auth collections |
RequireSuperuserAuth() |
Alias for RequireAuth("_superusers") |
RequireSuperuserOrOwnerAuth("id") |
Allow superusers OR the auth record whose id matches the named path param |
Gzip() |
Gzip-compress the response |
BodyLimit(bytes) |
Override the default 32MB request body cap (0 = no limit) |
SkipSuccessActivityLog() |
Suppress activity log for successful responses |
Path details:
- Patterns follow
net/http.ServeMux:{name}= single segment,{name...}= catch-all. - A trailing
/acts as a prefix wildcard; use{$}to anchor to the exact path only. - Always prefix custom routes with
/api/{yourapp}/- do not put them under/api/alone, which collides with built-in collection / realtime / settings endpoints. - Order: global middlewares → group middlewares → route middlewares → handler. Use negative priorities to run before built-ins if needed.
Reference: Go Routing · JS Routing
16. Read Settings via app.Settings(), Encrypt at Rest with PB_ENCRYPTION
Impact: HIGH (Hardcoded secrets and unencrypted settings storage are the #1 source of credential leaks)
PocketBase stores every runtime-mutable setting (SMTP credentials, S3 keys, OAuth2 client secrets, JWT secrets for each auth collection) in the _params table as JSON. Admin UI edits write to the same place. There are two knobs that matter: (1) how you read settings from Go/JS - always via app.Settings() at call time, never captured at startup; (2) how they are stored on disk - set the PB_ENCRYPTION env var to a 32-char key so the whole blob is encrypted at rest. Without encryption, anyone with a copy of data.db has your SMTP password, OAuth2 secrets, and every collection's signing key.
Incorrect (hardcoded secret, captured at startup, unencrypted at rest):
// ❌ Secret compiled into the binary - leaks via `strings ./pocketbase`
const slackWebhook = "https://hooks.slack.com/services/T00/B00/XXXX"
// ❌ Captured once at startup - if an admin rotates the SMTP password via the
// UI, this stale value keeps trying until restart
var smtpHost = app.Settings().SMTP.Host
// ❌ No PB_ENCRYPTION set - `sqlite3 pb_data/data.db "SELECT * FROM _params"`
// prints every secret in plaintext
./pocketbase serve
Correct (env + settings lookup at call time + encryption at rest):
# Generate a 32-char encryption key once and store it in your secrets manager
# (1Password, SOPS, AWS SSM, etc). Commit NOTHING related to this value.
openssl rand -hex 16 # 32 hex chars
# Start with the key exported - PocketBase AES-encrypts _params on write
# and decrypts on read. Losing the key == losing access to settings.
export PB_ENCRYPTION="3a7c...deadbeef32charsexactly"
./pocketbase serve
// Reading mutable settings at call time - reflects live UI changes
func notifyAdmin(app core.App, msg string) error {
meta := app.Settings().Meta
from := mail.Address{Name: meta.SenderName, Address: meta.SenderAddress}
// ...
}
// Mutating settings programmatically (e.g. during a migration)
settings := app.Settings()
settings.Meta.AppName = "MyApp"
settings.SMTP.Enabled = true
settings.SMTP.Host = os.Getenv("SMTP_HOST") // inject from env at write time
if err := app.Save(settings); err != nil {
return err
}
// JSVM
onBootstrap((e) => {
e.next();
const settings = $app.settings();
settings.meta.appName = "MyApp";
$app.save(settings);
});
// At send-time
const meta = $app.settings().meta;
Secrets that do NOT belong in app.Settings():
- Database encryption key itself →
PB_ENCRYPTIONenv var (not in the DB, obviously) - Third-party webhooks your code calls (Slack, Stripe, etc) → env vars, read via
os.Getenv/$os.getenv - CI tokens, deploy keys → your secrets manager, not PocketBase
app.Settings() is for things an admin should be able to rotate through the UI. Everything else lives in env vars, injected by your process supervisor (systemd, Docker, Kubernetes).
Key details:
PB_ENCRYPTIONmust be exactly 32 characters. Anything else crashes at startup.- Losing the key is unrecoverable - the settings blob cannot be decrypted, and the server refuses to boot. Back up the key alongside (but separately from) your
pb_databackups. - Rotating the key: start with the old key set, call
app.Settings()→app.Save(settings)to re-encrypt under the new key, then restart with the new key. Do this under a maintenance window. - Settings changes fire
OnSettingsReload- use it if you have in-memory state that depends on a setting (e.g. a rate limiter sized fromapp.Settings().RateLimits.Default). - Do not call
app.Settings()in a hot loop. It returns a fresh copy each time. Cache for the duration of a single request, not the process. app.Save(settings)persists and broadcasts the reload event. Mutating the returned struct without saving is a no-op.
Reference: Settings · OnSettingsReload hook
17. Test Hooks and Routes with tests.NewTestApp and ApiScenario
Impact: HIGH (Without the tests package you cannot exercise hooks, middleware, and transactions in isolation)
PocketBase ships a tests package specifically for integration-testing Go extensions. tests.NewTestApp(testDataDir) builds a fully-wired core.App over a temp copy of your test data directory, so you can register hooks, fire requests through the real router, and assert on the resulting DB state without spinning up a real HTTP server or touching pb_data/. The tests.ApiScenario struct drives the router the same way a real HTTP client would, including middleware and transactions. Curl-based shell tests cannot do either of these things.
Incorrect (hand-rolled HTTP client, shared dev DB, no hook reset):
// ❌ Hits the actual dev server - depends on side-effects from a previous run
func TestCreatePost(t *testing.T) {
resp, _ := http.Post("http://localhost:8090/api/collections/posts/records",
"application/json",
strings.NewReader(`{"title":"hi"}`))
if resp.StatusCode != 200 {
t.Fatal("bad status")
}
// ❌ No DB assertion, no cleanup, no hook verification
}
Correct (NewTestApp + ApiScenario + AfterTestFunc assertions):
// internal/app/posts_test.go
package app_test
import (
"net/http"
"strings"
"testing"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
"myapp/internal/hooks" // your hook registration
)
// testDataDir is a checked-in pb_data snapshot with your collections.
// Create it once with `./pocketbase --dir ./test_pb_data migrate up`
// and commit it to your test fixtures.
const testDataDir = "../../test_pb_data"
func TestCreatePostFiresAudit(t *testing.T) {
// Each test gets its own copy of testDataDir - parallel-safe
app, err := tests.NewTestApp(testDataDir)
if err != nil {
t.Fatal(err)
}
defer app.Cleanup() // REQUIRED - removes the temp copy
// Register the hook under test against this isolated app
hooks.RegisterPostHooks(app)
scenario := tests.ApiScenario{
Name: "POST /api/collections/posts/records as verified user",
Method: http.MethodPost,
URL: "/api/collections/posts/records",
Body: strings.NewReader(`{"title":"hello","slug":"hello"}`),
Headers: map[string]string{
"Authorization": testAuthHeader(app, "users", "alice@example.com"),
"Content-Type": "application/json",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"title":"hello"`,
`"slug":"hello"`,
},
NotExpectedContent: []string{
`"internalNotes"`, // the enrich hook should hide this
},
ExpectedEvents: map[string]int{
"OnRecordCreateRequest": 1,
"OnRecordAfterCreateSuccess": 1,
"OnRecordEnrich": 1,
},
AfterTestFunc: func(t testing.TB, app *tests.TestApp, res *http.Response) {
// Assert side-effects in the DB using the SAME app instance
audits, err := app.FindRecordsByFilter(
"audit",
"action = 'post.create'",
"-created", 10, 0,
)
if err != nil {
t.Fatal(err)
}
if len(audits) != 1 {
t.Fatalf("expected 1 audit record, got %d", len(audits))
}
},
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
}
scenario.Test(t)
}
Table-driven variant (authz matrix):
func TestPostsListAuthz(t *testing.T) {
for _, tc := range []struct {
name string
auth string // "", "users:alice", "users:bob", "_superusers:root"
expect int
}{
{"guest gets public posts", "", 200},
{"authed gets own + public", "users:alice", 200},
{"superuser sees everything", "_superusers:root",200},
} {
t.Run(tc.name, func(t *testing.T) {
app, _ := tests.NewTestApp(testDataDir)
defer app.Cleanup()
hooks.RegisterPostHooks(app)
tests.ApiScenario{
Method: http.MethodGet,
URL: "/api/collections/posts/records",
Headers: authHeaderFor(app, tc.auth),
ExpectedStatus: tc.expect,
TestAppFactory: func(t testing.TB) *tests.TestApp { return app },
}.Test(t)
})
}
}
Unit-testing a hook in isolation (no HTTP layer):
func TestAuditHookRollsBackOnAuditFailure(t *testing.T) {
app, _ := tests.NewTestApp(testDataDir)
defer app.Cleanup()
hooks.RegisterPostHooks(app)
// Delete the audit collection so the hook's Save fails
audit, _ := app.FindCollectionByNameOrId("audit")
_ = app.Delete(audit)
col, _ := app.FindCollectionByNameOrId("posts")
post := core.NewRecord(col)
post.Set("title", "should rollback")
post.Set("slug", "rollback")
if err := app.Save(post); err == nil {
t.Fatal("expected Save to fail because audit hook errored")
}
// Assert the post was NOT persisted (tx rolled back)
_, err := app.FindFirstRecordByFilter("posts", "slug = 'rollback'", nil)
if err == nil {
t.Fatal("post should not exist after rollback")
}
}
Rules:
- Always
defer app.Cleanup()- otherwise temp directories leak under/tmp. - Use a checked-in
test_pb_data/fixture with the collections you need. Do not depend on the devpb_data/- tests must be hermetic. - Register hooks against the test app, not against a package-level
appsingleton. The test app is a fresh instance each time. ExpectedEventsasserts that specific hooks fired the expected number of times - use it to catch "hook silently skipped because someone forgote.Next()" regressions.AfterTestFuncruns with the same app instance the scenario used, so you can query the DB to verify side-effects.- Parallelize with
t.Parallel()-NewTestAppgives each goroutine its own copy, so there's no shared state. - Tests run pure-Go SQLite (
modernc.org/sqlite) - no CGO, no extra setup, works ongo test ./...out of the box. - For JSVM, there is no equivalent test harness yet - test pb_hooks by booting
tests.NewTestAppwith thepb_hooks/directory populated and exercising the router from Go. Pure-JS unit testing of hook bodies requires extracting the logic into arequire()able module.
Reference: Testing · tests package GoDoc
18. Use RunInTransaction with the Scoped txApp, Never the Outer App
Impact: CRITICAL (Mixing scoped and outer app inside a transaction silently deadlocks or writes outside the tx)
app.RunInTransaction (Go) and $app.runInTransaction (JS) wrap a block of work in a SQLite write transaction. The callback receives a transaction-scoped app instance (txApp / txApp). Every database call inside the block must go through that scoped instance - reusing the outer app / $app bypasses the transaction (silent partial writes) or deadlocks (SQLite allows only one writer).
Incorrect (outer app used inside the tx block):
// ❌ Uses the outer app for the second Save - deadlocks on the writer lock
err := app.RunInTransaction(func(txApp core.App) error {
user := core.NewRecord(usersCol)
user.Set("email", "a@b.co")
if err := txApp.Save(user); err != nil {
return err
}
audit := core.NewRecord(auditCol)
audit.Set("user", user.Id)
return app.Save(audit) // ❌ NOT txApp - blocks forever
})
Correct (always txApp inside the block, return errors to roll back):
err := app.RunInTransaction(func(txApp core.App) error {
user := core.NewRecord(usersCol)
user.Set("email", "a@b.co")
if err := txApp.Save(user); err != nil {
return err // rollback
}
audit := core.NewRecord(auditCol)
audit.Set("user", user.Id)
if err := txApp.Save(audit); err != nil {
return err // rollback
}
return nil // commit
})
if err != nil {
return err
}
// JSVM - the callback receives the transactional app
$app.runInTransaction((txApp) => {
const user = new Record(txApp.findCollectionByNameOrId("users"));
user.set("email", "a@b.co");
txApp.save(user);
const audit = new Record(txApp.findCollectionByNameOrId("audit"));
audit.set("user", user.id);
txApp.save(audit);
// throw anywhere in this block to roll back the whole tx
});
Rules of the transaction:
- Use only
txApp/ the callback's scoped app inside the block. Capturing the outerappdefeats the purpose and can deadlock. - Inside event hooks,
e.Appis already the transactional app when the hook fires inside a tx - prefer it over a captured parent-scopeappfor the same reason. - Return an error (Go) or
throw(JS) to roll back. A successful return commits. - SQLite serializes writers - keep transactions short. Do not make HTTP calls, send emails, or wait on external systems inside the block.
- Do not start a transaction inside another transaction on the same app - nested
RunInTransactionontxAppis supported and reuses the existing transaction, but nested calls on the outerappwill deadlock. - Hooks (
OnRecordAfterCreateSuccess, etc.) fired from aSaveinside a tx run inside that tx. Anything they do throughe.Appparticipates in the rollback; anything they do through a captured outerappdoes not.
Reference: Go database · JS database