PocketBase is written in Go — and you can use it as a framework
Most people know PocketBase as “that backend in a single file.” Download a binary, run it, and you’ve got a full backend with authentication, realtime subscriptions, file storage, and an admin dashboard. It’s neat for prototyping.
But here’s what many Go developers miss: PocketBase is also a Go framework. You can import it as a module, extend it with custom routes, add middleware, hook into lifecycle events, and build a full production backend — all while keeping your own Go code in the driver’s seat.
Let’s look at how PocketBase works from a Go perspective and how you can use it to build something real.
PocketBase as a Go Framework
When you use PocketBase as a standalone binary, you’re running a pre-built Go application. But the pocketbase/pocketbase repository exposes its entire core as importable Go packages. This means you can create your own main.go, import PocketBase, and treat it like any other Go 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. But now you own the process. You can add whatever you want before calling app.Start().
Adding Custom Routes in Go
PocketBase uses an Echo-based router internally. When you extend it from Go, you register routes through PocketBase’s 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 here. The OnServe() hook fires when the HTTP server starts. Inside it, you get access to the router and can register routes like you would with any Go HTTP framework. The e.Auth field gives you the authenticated record if the request included a valid token — PocketBase handles the authentication token validation for you.
This pattern should feel familiar if you’ve worked with middleware in Go. If you’re new to how middleware chains work in Go web frameworks, the functional options pattern post covers a related idea about composable configurations.
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 works through PocketBase’s event system. Every record mutation triggers hooks. The realtime broker listens to these hooks and fans out events to connected SSE clients.
You can hook into the same system. 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 hook system uses a chain-of-responsibility pattern. Calling e.Next() passes control to the next handler in the chain. If you skip it, you break the chain — which can be useful if you want to block an operation. This is a common Go pattern you see in HTTP middleware stacks, and PocketBase applies it consistently across all its hooks.
There are hooks for basically everything: before/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 Go driver, specifically via a CGo-free SQLite implementation. All data — users, records, files metadata — lives in a single SQLite database file.
You can run raw SQL queries directly through the app’s DB() method, which gives you access to 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, parameterized queries, and transactions. It’s lightweight and stays close to SQL, which makes it easy to reason about.
For more complex operations, you can also use the Records API to interact with data without writing SQL:
// 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"},
)
This gives you a type-safe way to work with records while still keeping the flexibility of raw filters.
Authentication Built Into the Framework
PocketBase ships with a full authentication system. It supports email/password, OAuth2 providers, and token-based auth out of the box. In Go, you can interact with the auth system programmatically:
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. PocketBase uses JWT for all its auth tokens, and the signing key is derived from the app’s settings. You don’t need to configure any of this — it works out of the box. But you can customize token duration and other settings through the admin UI or programmatically.
If you’re building Go APIs that need authentication but don’t want to wire up a full auth system from scratch, this saves a lot of work. It’s comparable to what you’d build yourself with something like golang-jwt/jwt, but with user management, password hashing, and OAuth2 flows already done.
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. You can also write your own middleware following the same pattern — it’s just a function that wraps the handler.
When Would You Use This?
PocketBase as a Go framework makes sense in specific situations:
- You need a backend quickly but want to write custom business logic in Go.
- You want authentication, file uploads, and realtime without building them yourself.
- You’re building internal tools or MVPs where SQLite’s single-file simplicity is a feature.
- You want to embed a backend into a Go application you’re already building.
It’s less suitable when you need a distributed database, complex multi-service architectures, or when you’re already committed to PostgreSQL or another database. SQLite is surprisingly capable for many workloads, but it has limits — especially around write concurrency.
If you’re interested in how Go handles concurrency patterns that might complement PocketBase’s event system, the context in Go post covers how to manage cancellation and timeouts in your handlers. And for debugging issues in extended PocketBase apps, using the Go race detector is worth knowing about, especially if you add concurrent processing to your hooks.
Running in Production
Since your PocketBase app is just a Go binary, deployment is straightforward. Build it, copy it to your server, run it. The entire state lives in a few files: the SQLite database, an pb_data directory for uploaded files, 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). For scaling, you’d put a reverse proxy in front and consider read replicas using tools like Litestream for SQLite replication.
Wrapping Up
PocketBase is one of those projects that looks simple on the surface but has a thoughtful Go API underneath. The hook system, the query builder, the auth layer — they’re all well-designed Go interfaces that you can extend without fighting the framework.
If you’ve been building Go backends from scratch and wiring together routers, auth libraries, and database layers every time, PocketBase is worth trying as a foundation. You keep all the flexibility of writing Go, but skip the boilerplate you’ve written a dozen times before.