Mastering PocketBase: Building a Flexible Backend Framework for SaaS and Mobile Apps

Mastering PocketBase: Building a Flexible Backend Framework for SaaS and Mobile Apps

A step-by-step guide to setting up PocketBase as a framework, integrating plugins, and streamlining configurations for scalable development.

·

7 min read

What is PocketBase?

PocketBase describes itself as an "Open Source backend for your next SaaS and Mobile app in 1 file." Simply put, PocketBase is a powerful and versatile open-source backend designed to simplify the development process for SaaS and mobile applications. At its core, it includes an embedded SQLite database, offering a lightweight and efficient way to manage data. Additionally, PocketBase supports real-time subscriptions, making it ideal for apps that rely on live data updates, such as chat applications or collaborative tools.

PocketBase also comes equipped with built-in authentication features for managing user accounts securely and efficiently. Its admin UI provides a user-friendly interface to handle backend tasks without requiring extensive coding. The REST-like API enables seamless interaction with the backend through standard HTTP requests, making it accessible even to developers with limited backend experience.

One of PocketBase's standout features is its flexibility. You can use it as a standalone application, running your entire backend on PocketBase, or integrate it as a Go framework, leveraging its capabilities within larger Go projects.

Purpose of This Series

This article and the upcoming series aim to go beyond the PocketBase documentation by offering insights and practical advice from personal experience. We’ll dive deep into how to effectively use PocketBase as a framework, showcasing its full potential to elevate your development projects.

Initial Setup

For this guide, we will use PocketBase v0.23.x and Go 1.23+.

This section closely follows the official PocketBase documentation, focusing on setting up a basic Go application with PocketBase as the backend. By the end of this section, you'll have a minimal, functional Go application built on PocketBase, ready for further development and customisation.

Folder Structure

Here’s the initial folder structure for our project:

myapp:
    → go.mod
    → go.sum
    → main.go

Let’s start with the main.go file:

// main.go
package main

import (
    "log"

    "github.com/pocketbase/pocketbase"
)

func main() {
    // Initialise a new PocketBase app instance.
    app := pocketbase.New()

    // Start the PocketBase app server, and handle any errors.
    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Next, create the go.mod and go.sum files by running:

go mod init myapp && go mod tidy

You can now test your setup by running:

go run . serve

Plugins

A common question in the PocketBase community is how to configure PocketBase as a framework while retaining the functionality of its standalone version. This involves integrating several powerful features and plugins.

With this setup, you can enable the following:

  • JavaScript Hooks: Implemented via PocketBase's built-in JavaScript Virtual Machine (JSVM), these hooks support auto-reloading on changes and configurable pre-warmed runtimes for optimised performance.

  • JavaScript Migrations: Simplify database schema updates with automatic migrations and a CLI for managing changes over time.

  • Serving Static Content: Configure PocketBase to serve static files like images, stylesheets, and JavaScript directly from your app.

To get started, create a plugins directory with the following structure:

myapp:
    → plugins:
        → plugins.go
    → go.mod
    → go.sum
    → main.go

Here’s the implementation for plugins/plugins.go:

// plugins/plugins.go
package plugins

import (
    "net/http"
    "os"
    "path/filepath"
    "strings"

    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/apis"
    "github.com/pocketbase/pocketbase/core"
    "github.com/pocketbase/pocketbase/plugins/jsvm"
    "github.com/pocketbase/pocketbase/plugins/migratecmd"
    "github.com/pocketbase/pocketbase/tools/hook"
)

func defaultPublicDir() string {
    if strings.HasPrefix(os.Args[0], os.TempDir()) {
        return "./pb_public"
    }

    return filepath.Join(os.Args[0], "../pb_public")
}

func Register(app *pocketbase.PocketBase) {
    // Register the CLI flags.
    var hooksDir string
    var hooksWatch bool
    var hooksPool int
    var migrationsDir string
    var automigrate bool
    var publicDir string
    var indexFallback bool

    app.RootCmd.PersistentFlags().StringVar(
        &hooksDir,
        "hooksDir",
        "",
        "the directory with the JS app hooks",
    )

    app.RootCmd.PersistentFlags().BoolVar(
        &hooksWatch,
        "hooksWatch",
        true,
        "auto restart the app on pb_hooks file change",
    )

    app.RootCmd.PersistentFlags().IntVar(
        &hooksPool,
        "hooksPool",
        15,
        "the total prewarm goja.Runtime instances for the JS app hooks execution",
    )

    app.RootCmd.PersistentFlags().StringVar(
        &migrationsDir,
        "migrationsDir",
        "",
        "the directory with the user defined migrations",
    )

    app.RootCmd.PersistentFlags().BoolVar(
        &automigrate,
        "automigrate",
        true,
        "enable/disable auto migrations",
    )

    app.RootCmd.PersistentFlags().StringVar(
        &publicDir,
        "publicDir",
        defaultPublicDir(),
        "the directory to serve static files",
    )

    app.RootCmd.PersistentFlags().BoolVar(
        &indexFallback,
        "indexFallback",
        true,
        "fallback the request to index.html on missing static path (eg. when pretty urls are used with SPA)",
    )

    app.RootCmd.ParseFlags(os.Args[1:])

    // Register the plugins.
    jsvm.MustRegister(app, jsvm.Config{
        MigrationsDir: migrationsDir,
        HooksDir:      hooksDir,
        HooksWatch:    hooksWatch,
        HooksPoolSize: hooksPool,
    })

    migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
        TemplateLang: migratecmd.TemplateLangJS,
        Automigrate:  automigrate,
        Dir:          migrationsDir,
    })

    app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
        Func: func(e *core.ServeEvent) error {
            if !e.Router.HasRoute(http.MethodGet, "/{path...}") {
                e.Router.GET("/{path...}", apis.Static(os.DirFS(publicDir), indexFallback))
            }

            return e.Next()
        },
        Priority: 999,
    })
}

Register the plugin in your main.go file:

// main.go
package main

import (
    "log"
    "myapp/plugins"

    "github.com/pocketbase/pocketbase"
)

func main() {
    // Initialise a new PocketBase app instance.
    app := pocketbase.New()

    // Register the plugins with the PocketBase app instance.
    plugins.Register(app)

    // Start the PocketBase app server, and handle any errors.
    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Configuration

To ensure consistent behaviour and simplify environment-specific settings, this section details how to configure your PocketBase instance. Unlike the official PocketBase documentation, which recommends applying settings through migrations, I prefer to enforce these settings programmatically during the app's bootstrap process. Here’s why this approach is both safer and better suited for modern CI/CD pipelines:

  1. Consistency Across Deployments:
    Programmatically enforcing settings ensures that every deployment starts with the same configuration. If settings are only applied during migrations, they are executed once and remain unchanged unless explicitly updated. This can lead to divergence in settings over time if the environment changes, but the migrations are not re-run.

  2. Version Control for Settings:
    By defining settings in code, changes to configurations are tracked in your version control system (e.g., Git). This provides a clear audit trail of what changed, when, and why. Migrations, while useful for database schema updates, lack this direct visibility for settings.

  3. Integration with CI/CD Pipelines:
    Modern CI/CD pipelines are designed to promote automation and consistency. Storing settings as environment variables and enforcing them during the bootstrap process aligns with these principles. It allows for seamless configuration management across development, staging, and production environments, reducing the risk of misconfiguration.

  4. Immediate Feedback on Configuration Issues:
    Misconfigured settings are caught during the application startup, making them easier to identify and resolve. With migrations, misconfigurations might only surface during runtime, potentially leading to unexpected behaviour in live environments.

myapp:
    → config:
        → config.go
    → plugins:
        → plugins.go
    → .env.local
    → go.mod
    → go.sum
    → main.go

Here’s a sample .env.local file:

APP_NAME=myapp
APP_LOGS_MAX_DAYS=7
APP_LOGS_MIN_LEVEL=-4
APP_S3_ENABLED=false
APP_S3_ENDPOINT=
APP_S3_BUCKET=
APP_S3_REGION=
APP_S3_ACCESS_KEY=
APP_S3_SECRET=
APP_RATE_LIMITS_ENABLED=true
APP_RATE_LIMITS_RULES=[{ "maxRequests": 2, "duration": 4, "audience": "", "label": "*:auth" }, { "maxRequests": 16, "duration": 4, "audience": "", "label": "*:create" }, { "maxRequests": 32, "duration": 4, "audience": "", "label": "/api/" }]

This file defines the logic to load environment variables and enforce settings programmatically during the app's bootstrap phase:

// config/config.go
package config

import (
    "encoding/json"
    "os"
    "strconv"

    "github.com/joho/godotenv"
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
)

func updateSettingsFromEnvironment(e *core.BootstrapEvent) error {
    if err := e.Next(); err != nil {
        return err
    }

    settings := e.App.Settings()

    // Meta
    settings.Meta.AppName = os.Getenv("APP_NAME")

    // Logging
    if logsMaxDays, err := strconv.Atoi(os.Getenv("APP_LOGS_MAX_DAYS")); err != nil {
        e.App.Logger().Error(err.Error())
    } else {
        settings.Logs.MaxDays = logsMaxDays
    }

    if logsMinLevel, err := strconv.Atoi(os.Getenv("APP_LOGS_MIN_LEVEL")); err != nil {
        e.App.Logger().Error(err.Error())
    } else {
        settings.Logs.MinLevel = logsMinLevel
    }

    // File Storage
    if s3Enabled, err := strconv.ParseBool(os.Getenv("APP_S3_ENABLED")); err != nil {
        e.App.Logger().Error(err.Error())
    } else {
        settings.S3.Enabled = s3Enabled
    }

    settings.S3.Endpoint = os.Getenv("APP_S3_ENDPOINT")
    settings.S3.Bucket = os.Getenv("APP_S3_BUCKET")
    settings.S3.Region = os.Getenv("APP_S3_REGION")
    settings.S3.AccessKey = os.Getenv("APP_S3_ACCESS_KEY")
    settings.S3.Secret = os.Getenv("APP_S3_SECRET")

    // Rate Limiting
    if rateLimitsEnabled, err := strconv.ParseBool(os.Getenv("APP_RATE_LIMITS_ENABLED")); err != nil {
        e.App.Logger().Error(err.Error())
    } else {
        settings.RateLimits.Enabled = rateLimitsEnabled
    }

    settings.RateLimits.Rules = []core.RateLimitRule{}
    if err := json.Unmarshal([]byte(os.Getenv("APP_RATE_LIMITS_RULES")), &settings.RateLimits.Rules); err != nil {
        e.App.Logger().Error(err.Error())
    }

    e.App.Save(settings)

    return nil
}

func Register(app *pocketbase.PocketBase) {
    if app.IsDev() {
        if err := godotenv.Load(".env.local"); err != nil {
            app.Logger().Error(err.Error())
        }
    }

    app.OnBootstrap().BindFunc(updateSettingsFromEnvironment)
}

Register this configuration in your main.go:

// main.go
package main

import (
    "log"
    "myapp/config"
    "myapp/plugins"

    "github.com/pocketbase/pocketbase"
)

func main() {
    // Initialise a new PocketBase app instance.
    app := pocketbase.New()

    // Register the plugins with the PocketBase app instance.
    plugins.Register(app)

    // Configure the PocketBase app instance.
    config.Register(app)

    // Start the PocketBase app server, and handle any errors.
    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}

Next Steps

With this setup, you have a fully functional PocketBase application operating in framework mode. In the next article, we’ll explore creating hooks using Go and JavaScript to extend your app's functionality. Stay tuned for more insights and tips. Happy coding!