PocketBase

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.

Key idea —

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 . serve

Open 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.