PocketBase as a Go library
August 11, 2026
The win for today: the binary you've been running gets retired. In its
place, your own Go program — same admin UI, same API, same pb_data/ — plus
one custom route and one event hook that the prebuilt binary could never have.
Roughly thirty minutes. This is the lesson the mission has been pointing at.
The prebuilt binary is nothing special: it's roughly pocketbase.New() + app.Start() compiled
for you. Import the same package in your own main.go and you get everything from lessons 1–2,
with your code attached at two extension points: hooks (run inside PocketBase's lifecycle)
and routes (new endpoints on its router) (Go
overview).
1. The smallest possible PocketBase (10 min)
In a fresh directory:
go mod init pbapp
go get github.com/pocketbase/pocketbase// main.go
package main
import (
"log"
"github.com/pocketbase/pocketbase"
)
func main() {
app := pocketbase.New()
if err := app.Start(); err != nil {
log.Fatal(err)
}
}go run . serveOpen http://127.0.0.1:8090/_/ — it's all there. pb_data/ is created in the
working directory; copy the one from lessons 1–2 in beside main.go (stop the
old binary first) and your users, posts, and rules come with it.
2. A custom route (10 min)
Routes are registered in the OnServe hook, on se.Router
(Go routing):
import (
"github.com/pocketbase/pocketbase/core"
)
// inside main(), before app.Start():
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/stats", func(e *core.RequestEvent) error {
total, err := e.App.CountRecords("posts")
if err != nil {
return err
}
return e.JSON(200, map[string]any{"posts": total})
})
return se.Next()
})Restart, then curl http://127.0.0.1:8090/api/stats. That endpoint doesn't
exist in vanilla PocketBase — it's yours.
3. Your first hook (10 min)
Hooks intercept lifecycle events. Scope them to a collection by name, and
always call e.Next() to let the chain continue — code before it runs before
the operation, code after it runs after
(Go event hooks):
app.OnRecordCreate("posts").BindFunc(func(e *core.RecordEvent) error {
if e.Record.GetString("slug") == "" {
e.Record.Set("slug", slugify(e.Record.GetString("title")))
}
return e.Next() // validation + INSERT happen here
})Add a slug text field to posts in the dashboard, write a five-line
slugify (lowercase, spaces to hyphens — you've done this in Ink), restart,
and create a post as Alice with no slug. Fetch it back: the server filled it
in. No client — curl or SDK — can skip this logic, which is precisely what
rules alone couldn't express.
Check yourself
Q1. What does your compiled Go program replace?
Q2. In a hook handler, when does code placed after e.Next() run?
Q3. In app.OnRecordCreate("posts"), what does the argument do?
Primary source
Read the Go overview and skim the event hooks catalogue — the catalogue is worth ten minutes just to know what exists: request hooks, lifecycle hooks, success/error variants, mailer and realtime hooks.
Keep for reference
New cheat sheet for this layer: the Go extension cheat sheet — the minimal main.go, the hook timing model, and the route registration pattern.
Next
Lesson 4 makes the Go layer production-shaped: migrations as Go code, so
your collections live in version control instead of only in pb_data/ — the
thing that makes a PocketBase project reviewable and deployable like the rest
of your work.