
PocketBase is usually started as a prebuilt standalone executable, but the project also distributes itself as a regular Go library that can be embedded inside a custom Go application. Framework mode preserves the portable single-binary deployment model while exposing a programming surface for app-specific business logic through event hooks, custom routes, and console commands. This article explains the differences between the two usage modes, shows the minimal code required to embed PocketBase in a Go program, and maps out where custom logic lives in each mode. It also covers the pb_data, pb_migrations, pb_hooks, and pb_public directory contract that governs runtime data, schema changes, and JavaScript extensions. A dedicated section explains why JavaScript handlers run in isolated contexts and why variables declared outside a handler are undefined inside it. The material targets backend developers who have outgrown PocketBase's defaults and want compiled, version-controlled control over request handling and schema evolution.

In standalone mode, you download a prebuilt executable from the GitHub releases page, extract it, and run ./pocketbase serve. The server starts on http://127.0.0.1:8090, with the superusers dashboard (admin UI) at /_/ and the REST API under /api/; if a pb_public directory exists, its static content is served from the root path (PocketBase docs). On the first run, the console prints an installer link that you use to create the initial superuser account.
In framework mode, PocketBase is consumed as a regular Go library package: you import it, create an instance with pocketbase.New(), register custom logic through event hooks, and start it with app.Start(). Despite living inside your own program, the result is still a single portable executable that you build with go build and run as ./myapp serve (Extend with Go).
Switching to framework mode does not remove any built-in capability. The following behave the same in both modes:
/_/serve, migrate, and other subcommandsmain.go, not from the releases page.go.mod fixes the exact PocketBase version your code compiles against.Standalone mode fits the fastest prototyping loop, where customization is limited to *.pb.js scripts in pb_hooks. Framework mode is the step to take when logic needs compiled Go, external libraries, or finer control over the application flow. The JavaScript APIs largely mirror the Go ones, so a later migration from pb_hooks scripts to Go code tends to be gradual rather than a rewrite.
Two version-sensitive points deserve attention:
encoding/json/v2 (releases).OnRecordBeforeCreateRequest().Add(...), while current docs use OnRecordCreateRequest().BindFunc(...) with different event types.Since backward compatibility is not guaranteed before v1.0.0, an embedded app is effectively pinned to the major version it compiles against, and upgrading PocketBase becomes a dependency upgrade that requires reading the changelog and reviewing your hook code.

According to the official Go overview, embedding PocketBase takes three steps:
pocketbase.New(), or pocketbase.NewWithConfig(config) when you need custom configuration.app.Start().The canonical main.go from the GitHub README binds a function to OnServe(), registers a GET /hello route on se.Router, and forwards the serve event with se.Next():
package main
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
// registers new "GET /hello" route
se.Router.GET("/hello", func(e *core.RequestEvent) error {
return e.String(200, "Hello world!")
})
return se.Next()
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
Returning se.Next() is what lets the rest of the serve chain continue — without it, your binding would cut short the setup performed by PocketBase's built-in routes.
The setup steps are:
main.go.go mod init myapp && go mod tidy.go run main.go serve.serve argument mattersA frequently missed detail: app.Start() is not a library-only entry point. It hands control to PocketBase's built-in CLI, which parses os.Args, so your compiled binary keeps the standard commands (serve, migrate, and others) — you are extending an existing CLI rather than writing a server from scratch. The CLI is built on cobra, and you can add your own commands on app.RootCmd, as shown in the framework documentation. Running ./myapp --help lists every available command.
To produce a statically linked, self-contained executable:
CGO_ENABLED=0 go build
Start it with ./myapp serve. For other platforms, set GOOS/GOARCH before building, per the build instructions in the repository.
You can add static serving inside the same OnServe hook (requires the os and apis imports):
se.Router.GET("/{path...}", apis.Static(os.DirFS("./pb_public"), false))
Note that this is not automatic in framework mode, unlike the prebuilt executable — the standalone behavior is restored through plugins, covered in a later section.
The Go overview suggests looking at examples/base/main.go in the repository, and the README notes that building inside examples/base reproduces the minimal standalone executable. As editorial guidance rather than a documentation mandate, modeling a new project on examples/base is a reasonable starting point, since it demonstrates exactly how to reassemble the prebuilt binary's feature set in Go code.

In framework mode, custom logic attaches to three extension points: event hooks that intercept request and record lifecycles, custom routes added to the router, and console commands that extend the CLI. According to the official use-as-framework documentation, the same capabilities are exposed in both Go and JavaScript, so the language choice mainly affects verbosity, documentation quality, and how much control you have over execution flow — not what can be hooked.
The canonical example from the docs intercepts record create requests for a posts collection and forces non-superuser submissions into a pending status:
app.OnRecordCreateRequest("posts").BindFunc(func(e *core.RecordRequestEvent) error {
// if not superuser, overwrite the submitted "status" to "pending"
if !e.HasSuperuserAuth() {
e.Record.Set("status", "pending")
}
return e.Next()
})
Two things matter in this pattern:
e.Next() chains the hooks. Bound handlers form a chain, and returning e.Next() forwards the event to the remaining handlers and the default processing. This gives intercept-and-modify semantics — the record is changed in place, and the flow continues.e.Next(), a handler can return an error to reject the request entirely, so one API covers both modification and rejection.HTTP endpoints are registered inside OnServe on se.Router, which supports GET, POST, and the other standard verbs:
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/hello", func(e *core.RequestEvent) error {
return e.String(http.StatusOK, "Hello world!")
})
return se.Next()
})
The *core.RequestEvent passed to the handler exposes request data and response helpers such as e.String() for plain-text responses, with equivalent helpers for JSON payloads. Routes registered this way run alongside PocketBase's built-in dashboard and API routes rather than replacing them.
PocketBase's CLI — the one that provides serve and migration commands — is built on Cobra and exposed as app.RootCmd. Adding a subcommand extends that same CLI:
app.RootCmd.AddCommand(&cobra.Command{
Use: "hello",
Run: func(cmd *cobra.Command, args []string) {
print("Hello world!")
},
})
This is the natural home for data-fix jobs and scheduled maintenance commands: they run in the same binary and can use the app's data layer directly.
The official docs list Go's advantages as better-documented APIs, integration with any third-party Go library, and more control over the application flow — with the drawback that the Go APIs are slightly more verbose.
Hook names and event types are version-sensitive. The v0.23+ API uses OnServe / OnRecordCreateRequest with BindFunc, e.Next(), and event structs such as *core.RecordRequestEvent and *core.ServeEvent. Older material written for pre-0.23 versions instead shows OnRecordBeforeCreateRequest("posts").Add(func(e *core.RecordCreateEvent) error {...}) and route registration in OnBeforeServe with Echo-style handlers — an example of such older tutorials is easy to find. Because backward compatibility is not guaranteed before v1.0.0, always check snippet signatures against the PocketBase version pinned in your go.mod.

Framework mode does not merely change how you build PocketBase — it also removes capabilities that the prebuilt executable provides out of the box. A recurring community question is how to embed PocketBase as a Go framework without losing standalone behavior (jasonlei.com walkthrough). The answer is that JavaScript hooks, JavaScript migrations, and static file serving are opt-in plugins in framework mode, and each must be registered explicitly.
The jsvm plugin restores pb_hooks JavaScript support:
jsvm.MustRegister(app, jsvm.Config{
MigrationsDir: migrationsDir,
HooksDir: hooksDir,
HooksWatch: hooksWatch,
HooksPoolSize: hooksPool,
})
The configuration fields map to observable behavior:
--hooksDir).true), the app automatically restarts when a pb_hooks file changes, giving you auto-reload during development.goja.Runtime instances used for hook execution (default 15). Pre-warming avoids the cost of spinning up a fresh JavaScript runtime per handler invocation (PocketBase JSVM docs).The migratecmd plugin provides JavaScript migrations plus a CLI for managing them. Note that it takes app.RootCmd as a second argument, because it registers its own console commands:
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
TemplateLang: migratecmd.TemplateLangJS,
Automigrate: automigrate,
Dir: migrationsDir,
})
TemplateLangJS makes generated migration templates JavaScript files.true, toggleable via --automigrate; with it enabled, collection changes made in dev mode are written to migration files automatically.--migrationsDir).Static content is served by binding a catch-all GET route during OnServe, guarded by a HasRoute check so your own routes are never overridden, and bound with Priority: 999. The route uses apis.Static(os.DirFS(publicDir), indexFallback), where indexFallback (default true) falls back to index.html on missing paths — needed for SPA pretty URLs. The default public directory comes from a defaultPublicDir() helper that resolves pb_public relative to the executable, with a plain ./pb_public fallback when the binary path sits in the system temp directory.
The standard flags — --hooksDir, --hooksWatch (default true), --hooksPool (default 15), --migrationsDir, --automigrate (default true), --publicDir, --indexFallback (default true) — are registered on app.RootCmd.PersistentFlags() and read via app.RootCmd.ParseFlags(os.Args[1:]) before being passed into the plugin configs.
The recommended approach is to copy this registration structure from examples/base rather than reimplementing it: the prebuilt executables are themselves built from examples/base/main.go (PocketBase GitHub), so copying that structure preserves exact behavioral parity. Drift from the canonical registration — a missed flag here, an omitted plugin there — is a recurring source of "missing features" bug reports from developers who assumed framework mode includes everything standalone mode does.

The prebuilt PocketBase executable creates and manages two directories alongside itself — pb_data and pb_migrations — while pb_hooks and pb_public are opt-in directories you create manually. In framework mode the same contract applies, and the same CLI flags (--hooksDir, --migrationsDir, --publicDir) let you relocate each one. Understanding what belongs in each directory is what makes the single-binary model reproducible across machines.
pb_data holds everything the server generates at runtime: data.db (application data), auxiliary.db (log data and other ephemeral system metadata), and a storage/ folder for record files kept in local file storage. Because all of this is runtime data, the official docs recommend adding pb_data to .gitignore and not committing it to your repository.
pb_migrations contains JS migration files that track your collection changes. Unlike pb_data, it can be safely committed — this is how schema travels between environments: push the migration files to a repo, pull them on another machine, and starting the server recreates the collections. With the migratecmd plugin registered and Automigrate enabled (the default, controlled via the --automigrate flag), collection changes made through the admin UI in development mode are recorded as migration files automatically, so the UI workflow still produces reviewable artifacts.
pb_hooks is not created by default. Any *.pb.js files you place there are loaded in filename sort order — 01-first.pb.js runs before 02-second.pb.js — so naming controls load order when files depend on each other. The process auto-reloads on file changes, but per the JSVM docs, this auto-restart is supported only on UNIX-based platforms such as Linux and macOS. On Windows, plan to restart manually; treat this behavior as platform-sensitive rather than guaranteed.
pb_public is also not created by default. Create it manually and PocketBase serves its static content (HTML, CSS, images) on the main route, so no separate web server is needed. On deploy, the directory is swapped out wholesale rather than merged — managed hosting providers such as PocketBase Cloud replace pb_public with the deployed version while merging migration files instead.
As a recommendation: treat pb_migrations as the schema source of truth, so that any fresh server reconstructs the data model on startup, and back up pb_data — it is the only directory that cannot be reproduced from the repository.
One open question is how settings (app name, log retention, mail options) should travel. The official documentation recommends applying settings through migrations, while the jasonlei.com walkthrough argues for enforcing settings programmatically during OnBootstrap, so every deployment starts identically and settings changes stay visible in Git for CI/CD pipelines. These are competing recommendations, not settled doctrine; migrations suit simpler projects, while bootstrap enforcement fits pipelines where drift between environments is unacceptable.

The single most confusing behavior for new pb_hooks authors is that JavaScript handlers do not share lexical scope with their surrounding file. Per the official JSVM documentation, each handler function — whether a hook, route, or middleware — is serialized and executed in its own isolated context as a separate "program". Variables and functions declared outside the handler body simply do not exist inside it.
The documented failing example:
const name = "test"
onBootstrap((e) => {
e.next()
console.log(name) // <-- name will be undefined inside the handler
})
The trap is that this produces no load error and no warning — the handler registers and runs normally, and name is silently undefined. This makes it a runtime debugging problem, not a syntax problem. A related symptom: stack trace line numbers in *.pb.js files may be inaccurate for the same serialization reason.
The embedded goja engine executes each handler independently on a pre-warmed runtime. The prebuilt executable ships with a pool of 15 goja.Runtime instances (HooksPoolSize), which keeps handler execution fast without spinning up a fresh VM per invocation. Because no handler carries hidden outer state into a pooled runtime, the pool stays safe to reuse. The cost is that ordinary lexical sharing between a file's top level and its handlers is broken by design.
To share code between handlers, export it as a local module and require() it inside the handler:
onBootstrap((e) => {
e.next()
const config = require(`${__hooks}/config.js`)
console.log(config.name)
})
The __hooks global holds the absolute path to pb_hooks, which matters because relative require paths resolve against the current working directory, not the hooks directory. Only CommonJS-style modules are supported:
// pb_hooks/utils.js
module.exports = {
hello: (name) => console.log("Hello " + name)
}
Critical caveat: loaded modules use a shared registry across all handlers. Avoid mutating module state after load, since concurrent handlers can race on it.
window, fs, fetch, and buffer are unavailable.setTimeout/setInterval: no concurrent execution inside a single handler.json field values need get()/set() helpers.__hooks, $app, $apis.*, $os.*, $security.*.If a value is unexpectedly undefined in a handler, check its declaration scope first — then move it into a require'd module.

Both extension surfaces are equivalent in coverage. With either Go or JavaScript you can register custom routes, bind to event hooks to intercept and transform requests and responses, and add custom console commands. Choosing a language therefore does not limit what you can hook — it mainly affects verbosity, documentation quality, and control over execution flow (official docs).
Go is PocketBase's primary language, and the documented advantages are:
The cost is verbosity: the Go APIs are slightly more verbose, especially for newcomers, and every change requires a compile step (use-as-framework docs).
PocketBase embeds an ES5 JavaScript engine (goja) that acts as a pluggable wrapper around the existing Go APIs. The performance penalty is described as negligible in most cases, because JS code simply invokes the Go functions underneath. The practical win is iteration speed: edit a pb_hooks/*.pb.js file, and on UNIX platforms the process automatically restarts and reloads the hooks (in framework mode this is the HooksWatch option, enabled by default). The JS APIs mirror the Go APIs with two main differences: camelCase naming ($app.findRecordById vs. app.FindRecordById) and exceptions thrown instead of returned err values (JSVM overview).
Because the JSVM mirrors the Go APIs, the documentation states you can migrate gradually from JavaScript to Go without major code changes. The two typical documented triggers are hitting a performance bottleneck with the JS implementation, or wanting more control over the execution flow than the JS wrapper provides.
These are editorial suggestions, not documented rules:
A reasonable trajectory: prototype in JavaScript while the schema is still churning and logic shapes change weekly, then promote stable, hot, or safety-critical handlers to Go once their shape is settled. Because the APIs mirror each other, this promotion is mostly a translation exercise rather than a rewrite.

During development, run the application straight from source:
go run . serve
For a release, build a static executable:
CGO_ENABLED=0 go build
The result is a statically linked, self-contained binary with no external runtime and no dependency folders — a contrast to Node-style deployments, where you typically ship a node_modules tree alongside the code (GitHub README, codeart.co.ke). You copy one file to the target machine and start it with ./myapp serve. Disabling CGO is safe for the default setup because PocketBase uses a pure Go SQLite port (modernc.org/sqlite), so no C toolchain is needed on the build or target machine; registering a custom SQLite driver may reintroduce CGO requirements and change the recipe (Go overview).
For a reference layout, the examples/base directory in the repository builds the minimal standalone-equivalent executable, like the prebuilt ones on the releases page, and is the recommended starting point for a production structure (GitHub README).
Go's standard environment variables select the target platform:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build
The pure Go SQLite driver covers the common combinations: macOS (amd64/arm64), Windows (386/amd64/arm64), and a range of Linux architectures including arm, arm64, riscv64, and ppc64le (GitHub README). Because the output is static, the binary runs as-is on a clean target of the matching architecture.
A deployment consists of two artifacts:
pb_migrations directorypb_data is created on first run. It contains the SQLite database (data.db), the logs database (auxiliary.db), and the local file storage for uploads, making it the only stateful directory you need to back up and restore (PocketBase docs). It belongs in .gitignore, while pb_migrations stays under version control: migration files recreate collection changes when the server starts on a fresh machine, keeping schemas in sync across environments (codeart.co.ke).
Two points to verify against your pinned release rather than assume:
encoding/json/v2, which is not fully backward compatible (releases).go.mod and treat an upgrade as a code change requiring a hook-signature review, not a drop-in swap.pb_data as a deployment mechanism.serve against a throwaway pb_data before promoting a release. The maintainers themselves advise against pushing updates blindly to production and recommend testing locally first (releases).This closes the loop on the article's premise: embedded business logic and the standalone deployment story are not a trade-off. The library distribution explicitly promises app-specific business logic in "a single portable executable" (GitHub README).