skills/go-pocketbase-integration/SKILL.md
Integrate PocketBase as a Go library using the github.com/castle-x/goutils/pocketbase (gopb) package to build single-binary full-stack applications. Use when building Go applications that need user authentication, embedding PocketBase into Go binary, registering custom API routes, managing default users, serving embedded SPA frontend, or deploying single-binary applications. NOT for using PocketBase as a standalone separate process.
npx skillsauth add castle-x/skills-x go-pocketbase-integrationInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Embed PocketBase as a Go library using the gopb package (github.com/castle-x/goutils/pocketbase) to produce a single-binary full-stack application with built-in auth, SQLite database, admin UI, and custom API routes.
Single Binary
├── gopb.AppServer (wraps PocketBase core.App)
│ ├── Default user initialization (superuser + app user)
│ ├── Setup routes (status check + change password)
│ └── SPA serving helpers (production + dev proxy)
├── Custom API Routes (business logic)
├── Migrations (schema version control)
└── Embedded SPA Frontend (go:embed)
PocketBase runs in-process — no separate service, no HTTP calls for auth validation.
go get github.com/castle-x/goutils/pocketbase@latest
go get github.com/pocketbase/pocketbase@latest
package main
import (
"log"
"os"
"path/filepath"
"your-project/internal/server"
_ "your-project/internal/migrations"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
)
func getDataDir() string {
if dir := os.Getenv("DATA_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil {
return "app_data"
}
return filepath.Join(home, ".myapp")
}
func main() {
isDev := os.Getenv("ENV") == "dev"
app := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDataDir: getDataDir(),
DefaultDev: isDev,
})
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
Automigrate: isDev,
Dir: "internal/migrations",
})
srv := server.New(app)
if err := srv.Start(); err != nil {
log.Fatal(err)
}
}
package server
import (
gopb "github.com/castle-x/goutils/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
type AppServer struct {
*gopb.AppServer
dataPath string
}
func New(app core.App) *AppServer {
srv := gopb.New(app, gopb.Options{
DefaultEmail: "[email protected]",
DefaultPassword: "myapp123",
})
return &AppServer{AppServer: srv}
}
func (s *AppServer) Start() error {
s.OnServe().BindFunc(func(e *core.ServeEvent) error {
// 1. Setup routes (status check + change password)
s.RegisterSetupRoutes(e)
// 2. Business routes
api := e.Router.Group("/api")
api.Bind(apis.RequireAuth())
api.GET("/items", s.handleListItems)
// 3. Create default users if first run
s.EnsureDefaults()
// 4. Serve frontend (use build tags to switch)
s.serveFrontend(e)
return e.Next()
})
return s.AppServer.Start()
}
//go:build !development
// file: server_production.go
package server
import (
gopb "github.com/castle-x/goutils/pocketbase"
"github.com/pocketbase/pocketbase/core"
"your-project/site"
)
func (s *AppServer) serveFrontend(se *core.ServeEvent) {
gopb.ServeSPA(se, site.DistDirFS, []string{"/assets/", "/static/"})
}
//go:build development
// file: server_development.go
package server
import (
gopb "github.com/castle-x/goutils/pocketbase"
"github.com/pocketbase/pocketbase/core"
)
func (s *AppServer) serveFrontend(se *core.ServeEvent) {
gopb.ServeDevProxy(se, "localhost:5173")
}
| Function | Description |
|----------|-------------|
| gopb.New(app, opts...) | Create AppServer wrapping core.App |
| s.Start() | Launch PocketBase (blocks) |
| s.Opts() | Get resolved Options |
| Function | Description |
|----------|-------------|
| s.EnsureDefaults() | Create default superuser + user if collections empty |
| s.IsDefaultPassword(record) | Check if record uses default password |
| Function | Description |
|----------|-------------|
| s.RegisterSetupRoutes(se) | Register GET /status + POST /change-password |
Endpoints (under Options.SetupRoutePrefix, default /api/setup):
GET /status → {"needsPasswordChange": bool}POST /change-password → accepts {"password", "passwordConfirm"}The change-password endpoint syncs passwords to _superusers — any superuser still using the default password gets updated, keeping Admin UI access in sync.
| Function | Description |
|----------|-------------|
| gopb.ServeSPA(se, distFS, staticPaths) | Serve embedded SPA with cache + fallback |
| gopb.ServeDevProxy(se, host) | Proxy to Vite dev server |
// WRONG — record.GetString("password") returns empty string for hash fields
hash := record.GetString("password")
bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) // always fails
// CORRECT — use PocketBase's built-in method
record.ValidatePassword("plaintext") // true/false
PocketBase enforces a minimum password length of 8 characters for auth collections. If gopb.Options.DefaultPassword is shorter than 8 characters, s.EnsureDefaults() will fail with an error like:
ERROR gopb: failed to create default superuser
└─ {"error":{"password":"Must be at least 8 character(s)"}}
// WRONG — too short, will fail silently or log errors
srv := gopb.New(app, gopb.Options{
DefaultEmail: "[email protected]",
DefaultPassword: "myapp123", // only 7 chars!
})
// CORRECT — minimum 8 characters
srv := gopb.New(app, gopb.Options{
DefaultEmail: "[email protected]",
DefaultPassword: "myapp1234", // 8+ chars
})
PocketBase registers its own routes (e.g., GET /api/health, POST /api/collections/:collection/auth-with-password). Adding a custom route with the same method+path causes a runtime panic:
panic: pattern "GET /api/health" conflicts with pattern "GET /api/health"
Rule: Only register custom routes under paths that don't overlap with PocketBase's API. Common safe prefixes:
/api/v1/ or /api/setup/ (used by gopb)/api/{your-business-domain}/// WRONG — conflicts with PocketBase's built-in health endpoint
e.Router.GET("/api/health", handler) // PANIC at runtime
// CORRECT — use a unique prefix
apiAuth := e.Router.Group("/api")
apiAuth.Bind(apis.RequireAuth())
apiAuth.GET("/items", s.handleListItems)
_superusers vs users_superusers is for Admin UI (/_/) access only. It does NOT support public API authentication.users is for frontend app login via pb.collection("users").authWithPassword().handleChangePassword endpoint syncs passwords across both collections.The _superusers and users collections have independent auth sessions. Logging into the app does NOT log you into the Admin UI and vice versa. This is by design in PocketBase. Mitigation: keep passwords in sync (which gopb does automatically).
In routes protected by apis.RequireAuth(), e.Auth is the authenticated *core.Record. Access e.Auth.Id, e.Auth.Email(), e.Auth.GetString("field") directly.
Avoid hardcoding DefaultDataDir to "app_data". Instead, use ~/.appname/ as default with environment variable override:
func getDataDir() string {
if dir := os.Getenv("DATA_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil {
return "app_data" // fallback
}
return filepath.Join(home, ".appname")
}
func main() {
app := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDataDir: getDataDir(),
DefaultDev: isDev,
})
// ...
}
This ensures:
make clean doesn't accidentally delete production dataWhen accessing the frontend through a tunnel or reverse proxy (e.g., https://yourdomain.top), Vite will block requests with: "This host isyourdomain.top" is not allowed.
Three settings must be configured in vite.config.ts:
server: {
host: "0.0.0.0", // 1. Listen on all interfaces, not just localhost
port: 3000,
strictPort: true,
allowedHosts: ["yourdomain.top"], // 2. Allow your tunnel/proxy domain
proxy: {
"/api": { target: backendTarget, changeOrigin: true },
"/_/": { target: backendTarget, changeOrigin: true },
},
}
For the PocketBase backend, also proxy the /_/ route in both dev and production for Admin UI access.
A common mistake is dev: dev-backend dev-frontend which runs them sequentially (the first blocks forever). Use background jobs:
dev-backend:
ENV=dev go run -tags development ./cmd/server serve --http=localhost:8180
dev-frontend:
cd site && npx vite
dev:
@echo "Starting backend and frontend in parallel..."
@$(MAKE) dev-backend & $(MAKE) dev-frontend & wait
EnsureDefaults() placement in OnServe hookCall s.EnsureDefaults() after route registration but before s.serveFrontend(e). If called too early (before PocketBase finishes its own initialization), it may fail. The correct order:
s.OnServe().BindFunc(func(e *core.ServeEvent) error {
s.RegisterSetupRoutes(e) // 1. Setup routes first
// ... custom routes ...
s.EnsureDefaults() // 2. Create default users
s.serveFrontend(e) // 3. Serve frontend last (catch-all route)
return e.Next()
})
After successful login, always check whether the user needs to change their default password before granting access to the dashboard:
const authData = await pb.collection("users").authWithPassword(email, password);
setAuth({ id: authData.record.id, email: authData.record.email, ...authData.record }, authData.token);
// Check if first-run password change is required
const status = await checkSetupStatus();
if (status.needsPasswordChange) {
navigate("/change-password", { replace: true });
} else {
navigate("/", { replace: true });
}
The AuthGuard component must listen to pb.authStore.onChange and sync logout to Zustand. Otherwise, 401 responses from the API won't trigger a UI redirect:
useEffect(() => {
const unsubscribe = pb.authStore.onChange(() => {
if (!pb.authStore.isValid && isAuthenticated) {
logout();
navigate("/login", { replace: true });
}
});
return () => unsubscribe();
}, [isAuthenticated, logout, navigate]);
The pb-client.ts module restores auth from localStorage on import (before any React component mounts). The AUTH_STORAGE_KEY in pb-client.ts must match the name in Zustand's persist() middleware:
// pb-client.ts
const AUTH_STORAGE_KEY = "auth-storage";
// useAuth.ts
persist(/* ... */, { name: "auth-storage" }) // must match!
See references/frontend-patterns.md for PocketBase JS SDK setup, auth state management, and SPA embedding patterns.
references/project-structure.md — Project layout and file organizationreferences/backend-patterns.md — Routes, middleware, DB, migrations, hooks, cron, build tagsreferences/frontend-patterns.md — SPA embedding, PocketBase JS SDK, auth patternsreferences/deployment.md — Docker, systemd, data management, backuptools
Design specification for CLI TUI (Terminal User Interface). This skill provides comprehensive guidelines for implementing interactive terminal UI components, including page layout structure, color schemes, keyboard navigation, and multi-level navigation principles.
documentation
Guide for contributing new skills to the skills-x collection. This skill should be used when users want to add new open-source skills from external sources (like agentskills.io or anthropics/skills) to the skills-x repository. It covers the complete workflow from discovery to publishing.
tools
Use when designing or refining UIs that must be visually minimal, low-noise, and icon-forward while staying understandable for new users, especially when reducing text, consolidating controls, or streamlining dialogs, toolbars, search panels, or list results.
tools
Use when defining or updating Go CLI i18n rules in this repo, especially around locale files, env-based language selection, and key naming.