PocketBase is written in Go — and you can use it as a framework
Most people meet PocketBase as “that backend in a single file.” Download a binary, run it, and you’ve got authentication, realtime subscriptions, file storage and an admin dashboard. Neat for prototyping, and most people stop there.
Here’s what many Go developers miss: PocketBase is also a Go framework. You can import it as a module, add custom routes and middleware, hook into lifecycle events, and build a proper production backend with your own Go code in the driver’s seat. That second mode is the one I find genuinely interesting, so it’s the one this post is about.
PocketBase as a Go framework
The standalone binary is just a pre-built Go application. The pocketbase/pocketbase repository exposes its entire core as importable Go packages, so you can write your own main.go, import PocketBase, and treat it like any other library.
Here’s the minimal setup:
package main
import (
"log"
"github.com/pocketbase/pocketbase"
)
func main() {
app := pocketbase.New()
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
That’s it. Run go run main.go serve and you get the full PocketBase backend: API, admin UI, authentication, realtime, everything. The difference is that you now own the process. Anything you want to add goes in before app.Start().
Adding custom routes in Go
When you extend PocketBase from Go, you register routes through its event hooks. Here’s how to add a custom API endpoint:
package main
import (
"log"
"net/http"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/hello", func(e *core.RequestEvent) error {
// Access the authenticated user if present
user := e.Auth
name := "anonymous"
if user != nil {
name = user.GetString("name")
}
return e.JSON(http.StatusOK, map[string]string{
"message": "Hello, " + name,
})
})
return se.Next()
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
A few things to notice. The OnServe() hook fires when the HTTP server starts. Inside it you get the router, and registering routes feels like any other Go HTTP framework. The e.Auth field holds the authenticated record if the request carried a valid token, and PocketBase does the token validation before your handler runs. That’s exactly the kind of boilerplate I’m happy to stop writing.
This pattern should feel familiar if you’ve worked with middleware in Go. If composable configuration like this is new to you, the functional options pattern post covers a related idea.
How PocketBase handles realtime with Go
PocketBase supports realtime subscriptions over Server-Sent Events (SSE). Clients subscribe to changes on specific collections, and PocketBase pushes updates when records are created, updated or deleted.
From the Go side, this all rides on the event system. Every record mutation triggers hooks, and the realtime broker listens to those hooks and fans events out to connected SSE clients.
You can hook into the same machinery. Say you want to run custom logic every time a record in the “orders” collection is created:
package main
import (
"log"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func main() {
app := pocketbase.New()
// Runs after a record is successfully created
app.OnRecordAfterCreateSuccess("orders").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
log.Printf("New order created: %s, total: %v",
record.Id,
record.GetFloat("total"),
)
// You could send a notification, update inventory, etc.
// The record is already committed to the DB at this point.
return e.Next()
})
if err := app.Start(); err != nil {
log.Fatal(err)
}
}
The hooks form a chain of responsibility. Calling e.Next() passes control to the next handler in the chain; skipping it breaks the chain, which is occasionally what you want, because it’s how you veto an operation. If you’ve written HTTP middleware in Go you’ll recognise the shape immediately. What I like is the consistency: PocketBase applies the same pattern across every hook.
And there are hooks for basically everything: before and after create, update, delete, authentication, file upload, admin actions, and more. The full list is in the PocketBase hooks documentation.
The data layer: SQLite and Go’s database/sql
PocketBase uses SQLite through a CGo-free Go driver. All data, from users to records to file metadata, lives in a single SQLite database file.
When you need raw SQL, the app’s DB() method hands you a dbx.DB instance (PocketBase uses the pocketbase/dbx query builder):
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/stats", func(e *core.RequestEvent) error {
type OrderStats struct {
Count int `db:"count" json:"count"`
Total float64 `db:"total" json:"total"`
}
stats := OrderStats{}
err := e.App.DB().
NewQuery("SELECT COUNT(*) as count, SUM(total) as total FROM orders").
One(&stats)
if err != nil {
return e.JSON(500, map[string]string{"error": err.Error()})
}
return e.JSON(200, stats)
})
return se.Next()
})
The dbx query builder supports struct scanning with db tags, parameterised queries and transactions. It stays close to SQL, which I consider a feature: you can always see what query is actually running.
When you’d rather not write SQL, the Records API covers most cases:
// Find a single record by ID
record, err := app.FindRecordById("orders", "some_record_id")
// Find records with filters
records, err := app.FindRecordsByFilter(
"orders",
"total > {:minTotal} && status = {:status}",
"-created", // sort
10, // limit
0, // offset
dbx.Params{"minTotal": 100, "status": "pending"},
)
You keep type safety while still getting the flexibility of raw filters.
Authentication built into the framework
PocketBase ships with a full authentication system: email/password, OAuth2 providers and token-based auth, all working out of the box. And you can drive it programmatically from Go:
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.POST("/api/custom-login", func(e *core.RequestEvent) error {
// Find user by email
record, err := e.App.FindAuthRecordByEmail("users", "user@example.com")
if err != nil {
return e.JSON(401, map[string]string{"error": "user not found"})
}
// Validate password
if !record.ValidatePassword("their_password") {
return e.JSON(401, map[string]string{"error": "invalid password"})
}
// Generate auth token
token, err := record.NewAuthToken()
if err != nil {
return e.JSON(500, map[string]string{"error": "token generation failed"})
}
return e.JSON(200, map[string]string{
"token": token,
"id": record.Id,
})
})
return se.Next()
})
The NewAuthToken() method generates a JWT, signed with a key derived from the app’s settings. None of that needs configuring, though you can tune token duration and other settings through the admin UI or in code.
Wiring up auth by hand with something like golang-jwt/jwt is well-trodden ground, but it’s tedious, and the mistakes are expensive. Getting user management, password hashing and OAuth2 flows for free here is a genuine time-saver.
Middleware and request guards
You can protect your custom routes with middleware. PocketBase provides built-in middleware for requiring authentication:
app.OnServe().BindFunc(func(se *core.ServeEvent) error {
se.Router.GET("/api/protected", func(e *core.RequestEvent) error {
// e.Auth is guaranteed to be non-nil here
// because of the RequireAuth middleware
user := e.Auth
return e.JSON(200, map[string]string{
"user_id": user.Id,
"email": user.GetString("email"),
})
}).Bind(apis.RequireAuth())
return se.Next()
})
RequireAuth() returns a middleware that checks for a valid auth token and rejects the request with a 401 if none is found, which is why the handler above can assume e.Auth is non-nil. Writing your own middleware follows the same pattern. It’s just a function that wraps the handler.
When would you use this?
I’d reach for PocketBase-as-a-framework when I need a backend quickly but still want custom business logic in Go, when auth, file uploads and realtime would otherwise eat a week of setup, when I’m building an internal tool or MVP where SQLite’s single-file simplicity is a feature rather than a compromise, or when I want to embed a backend inside a Go application I’m already writing.
I wouldn’t use it for everything. If you need a distributed database, a multi-service architecture, or you’re already committed to PostgreSQL, this isn’t your foundation. SQLite is more capable than most people give it credit for, but write concurrency is a real limit, and you want to know it’s there before you commit.
If you’re adding concurrent processing to your hooks, two things are worth reading: the context in Go post on cancellation and timeouts in handlers, and the docs on using the Go race detector for when things get weird.
Running in production
Since your PocketBase app is just a Go binary, deployment is the usual Go story: build it, copy it to your server, run it. The entire state lives in a few files, the SQLite database and a pb_data directory for uploads, and that’s it.
go build -o myapp .
./myapp serve --http="0.0.0.0:8090"
For backups, you can copy the database file (PocketBase supports online backups through the admin API). If you outgrow one machine, put a reverse proxy in front and look at tools like Litestream for SQLite replication.
Wrapping up
PocketBase looks simple on the surface, but the Go API underneath is thoughtfully designed. The hook system, the query builder and the auth layer are all small, consistent interfaces you can extend without fighting the framework, and reading how they fit together is worthwhile even if you never deploy it.
If you’ve built enough Go backends to be bored of wiring routers to auth libraries to database layers, start your next side project from that ten-line main.go above. Keep the flexibility of writing Go. Skip the boilerplate you’ve written a dozen times before.