Documentation

Swapbook is a component workbench for htmx and hypermedia apps. A single binary reverse-proxies your running app and serves a gallery under /__sb/, so you build and review server-rendered components in isolation, with an htmx-aware inspector, live controls and mocked interactions.

Getting started // install & run

Swapbook has two pieces: the binary you run next to your app in development, and a tiny adapter in your app that describes your components over four HTTP endpoints under /_swapbook.

Install the binary:

# installs a `swapbook` binary on your PATH
go install github.com/Aejkatappaja/swapbook/cmd/swapbook@latest
swapbook --version

Run it against your app (say it listens on :8080), then open http://localhost:7007/__sb/:

swapbook --target :8080
If the gallery loads but says "no swapbook adapter", your app is running but has not mounted the adapter yet. That is the next step.

Writing stories // the API

A story is one component; a variant is one rendered state of it. A variant's render is just a callable that returns an HTML string, so it is decoupled from any template engine. Stories are grouped into sidebar sections. The concepts are identical across stacks; only the syntax changes.

Go

The Go adapter takes any Renderer (Render(ctx, io.Writer) error), which templ, html/template and gomponents all satisfy. The module has zero dependencies.

import adapter "github.com/Aejkatappaja/swapbook/adapters/go"

reg := adapter.New()
reg.CSSSrc = "/static/app.css" // injected into bare-fragment previews
reg.Viewports = []adapter.Viewport{{Name: "wide", Width: "1440px"}} // optional named widths

reg.RegisterIn("actions", "Button",
    adapter.Var("primary",   Button("Save", "primary")),
    adapter.Var("secondary", Button("Cancel", "secondary")),
)
reg.DocStory("Button", "The button primitive.")

Viewports set via reg.Viewports appear in the toolbar's width bar next to the built-in full/tablet/phone, and the width you pick is remembered per story. Other stacks emit the same viewports list in their manifest.

Django

from swapbook_adapter import Registry, variant

reg = Registry(css_src="/static/app.css")
reg.register("Button", group="actions", variants=[
    variant("primary",   lambda a: render_button("Save")),
    variant("secondary", lambda a: render_button("Cancel")),
])
urlpatterns = reg.urls  # mounts /_swapbook/*

Rails

require "swapbook"

REG = Swapbook::Registry.new(css_src: "/assets/app.css")
REG.register("Button", group: "actions", variants: [
  Swapbook.variant("primary", ->(a) { render_button("Save") }),
])
# config/routes.rb
mount REG => "/_swapbook"

Pass the registry as a constant (REG); a local variable is out of scope inside the routing block.

Laravel / PHP

require "swapbook.php";

$sb = new Swapbook();
$sb->register("Button", [
    sb_variant("primary", fn($a) => view("button")->render()),
], "actions");

// route /_swapbook/* to:
[$status, $ct, $body] = $sb->handle($method, $path, $query);

Any other stack

There is no adapter requirement. Answer the four endpoints under /_swapbook in any language and Swapbook drives it. See the protocol and the stdlib examples in examples/{python,node,ruby}/.

Controls // live knobs

A control adds a form input to the toolbar; changing it re-renders the variant. A control has a name, a type (text, number, bool, select), a default, and options for select. In Go, VarC receives typed Args:

reg.RegisterIn("actions", "Button",
    adapter.VarC("controls", []adapter.Control{
        {Name: "label",   Type: "text",   Default: "Save"},
        {Name: "variant", Type: "select", Default: "primary", Options: []string{"primary", "danger"}},
        {Name: "disabled", Type: "bool", Default: false},
    }, func(a adapter.Args) adapter.Renderer {
        return Button(a.String("label"), a.String("variant"), a.Bool("disabled"))
    }),
)

An absent control falls back to its default; a present-but-empty value is a real value, so you can clear a text field. Other stacks pass the control list alongside the render callable.

Mocks // canned responses

A mock declares a canned response for a route a variant's interactions will hit, so htmx works with no auth and no database. Swapbook rewrites the matching request to the mock before it leaves the browser.

reg.RegisterIn("interactive", "Todo list",
    adapter.Var("default", TodoList()).
        Mock("GET /ds/row", TodoRow()).
        Doc("Click + add row: the mock returns a new <li> htmx appends."),
)

Routes shared across stories (an autocomplete endpoint, a token fetch) can be declared once on the registry instead of on every variant, like Storybook's meta-level handlers. They merge into every variant; a variant's own mock for the same route wins.

reg.Mock("GET /app/exercises/search", ExerciseSuggestions())

A mock serves 200 by default. Give it a non-2xx status to preview a component reacting to a failure (hx-target-error, status-conditional swaps); Swapbook proxies the status through unchanged.

adapter.Var("invalid", SignupForm()).
    MockStatus("POST /signup", 422, ValidationErrors())

Other stacks take an optional status argument, e.g. mock(route, render, status=422) in Django.

Modes // mock · safe · live

The toolbar has three modes, deciding what happens to htmx requests fired from inside a story:

modebehavior
mockDeclared mocks are served locally; unmocked mutating requests (POST/PUT/DELETE/PATCH) are blocked. Full interaction, no auth, no DB. Default.
safeReal requests hit your app, but mutating requests are blocked. Preview against real read data.
liveEverything is real, including mutations. Use against a throwaway dev database.

A request already rerouted to a mock is never blocked; a non-mutating GET is never blocked.

CLI // flags

flagdefaultdescription
--target:8080Address of the app to proxy. Accepts :8080, localhost:8080 or a full URL.
--port7007Port the Swapbook UI is served on.
--headerHeader injected into every request to the target, as Name: value. Repeatable, so components behind auth render in live mode.
--versionPrint the version and exit.
Previews behind auth: in safe/live mode a preview hits your real app, so protected routes return 401. Pass the credential with --header 'Cookie: session=...' (repeatable) and Swapbook injects it into every proxied request. Use a throwaway dev session, never a production token; it appears in your shell history.

The UI auto-reloads when your app rebuilds and reconnects if the target goes down. All UI assets are served no-store, so a rebuilt binary never serves a stale gallery.

Adapters // built-in & custom

An adapter is the small render-agnostic piece in your app that answers the protocol. Built-in adapters live under adapters/{go,django,rails,php}/, with runnable demos under examples/.

stackmount
Gomux.Handle(adapter.MountPath+"/", ...)
Djangourlpatterns = reg.urls
Railsmount REG => "/_swapbook"
Laravel / PHProute /_swapbook/* to $sb->handle(...)

You do not need an adapter at all: any server answering the four endpoints works. A dependency-free reference is examples/python/target.py (about 140 lines of stdlib). Community adapters are welcome; the authoring guide derives one from the protocol step by step.

Protocol // four endpoints

The contract an app implements, under /_swapbook:

endpointpurpose
GET /manifest.jsonstories, variants, control schemas, asset hints
GET /preview/{id}/{variant}render a component (control args as query params)
GET /mocks/{id}/{variant}list a variant's mocked routes
ANY /mock/{id}/{variant}/{index}render a mock response

Only the first two are required. The full schema is in the protocol specification.

Limitations // honest edges

Swapbook is early and deliberately scoped. Worth knowing:

Swapbook strips security headers and proxies your app, so it is a local dev tool only. Never run it in production or expose it publicly.