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 take a viewports arg on the registry too (Django/Rails/PHP).

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);

Flask

from swapbook import Registry, variant

reg = Registry(css_src="/static/app.css")
reg.register("Button", [
    variant("primary", lambda a: render_button("Save")),
], group="actions")

app.register_blueprint(reg.blueprint)

Express

const { Registry, variant } = require("swapbook");

const reg = new Registry({ cssSrc: "/static/app.css" });
reg.register("Button", [
    variant("primary", () => renderButton("Save")),
], { group: "actions" });

app.use(reg.router());

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}/.

Several stylesheets or scripts

If your styles are split across files (an icon set, a font, the app's own CSS), declare them all. They are injected in the order you give, which for CSS is the cascade. The same holds for jsSrc. A single path stays a plain string, so nothing you already wrote changes.

reg.CSSSrcs = []string{"/static/icons.css", "/static/app.css"}   // Go

reg = Registry(css_src=["/static/icons.css", "/static/app.css"])   # Django / Flask

REG = Swapbook::Registry.new(css_src: ["/assets/icons.css", "/assets/app.css"])   # Rails

$sb->cssSrc = ['/css/icons.css', '/css/app.css'];   // PHP

const reg = new Registry({ cssSrc: ["/static/icons.css", "/static/app.css"] });   // Express

Go is the one adapter with a second field, CSSSrcs / JSSrcs, because its registry is typed; set one or the other, the plural wins. htmxSrc stays a single path: a document runs one htmx.

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.

The inspector normalizes htmx and, best effort, Turbo / Unpoly / Datastar. Long-lived SSE and WebSocket connections get their own lens: open, each message (with direction), and close are logged, so streamed updates are traceable even though there is no discrete request.

Play // drive & verify

A variant can attach a play: scripted interactions plus assertions so a story drives and verifies a flow, not just renders a state. Hit ▶ play and each step runs against the preview, reporting pass or fail. Steps are declarative (click, type, expect-text, expect-visible, wait), authored in the adapter's own language, so no eval and the same vocabulary works across stacks.

adapter.Var("default", TodoList()).
    Mock("GET /ds/row", TodoRow()).
    Play(
        adapter.Click(`[hx-get="/ds/row"]`),
        adapter.ExpectText("#rows", "New task"),
    )

Assertions poll for a few seconds, so a check after a click sees the htmx swap. A play stops at the first failing step, and pairs naturally with mock mode (drive the flow with no auth or DB).

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.
--host127.0.0.1Address to listen on. The workbench stays on your machine by default; pass 0.0.0.0 to reach it from a phone or a VM.
--headerHeader injected into every request to the target, as Name: value. Repeatable, so components behind auth render in live mode.
--insecureSkip TLS certificate verification for the target, for a dev app behind a self-signed certificate (a local reverse proxy, mkcert, an internal CA). Also accepted by swapbook check.
--versionPrint the version and exit.
A preview frame loads stylesheets and scripts only from paths on your app; cssSrc, jsSrc and htmxSrc pointing at an absolute URL are ignored, because the frame runs on Swapbook's origin next to the proxy that carries your credentials. The workbench also listens on 127.0.0.1 only. It proxies your app, strips the framing headers off its responses and forwards any --header credential, so anyone who can open it can drive your app as you. Pass --host 0.0.0.0 to preview from a phone or a VM, on a network you trust.
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.
Targets behind a self-signed certificate: if your app is served over TLS with a certificate nothing trusts (Traefik, Caddy, mkcert, a company CA), the handshake fails and the gallery stays empty. Pass --insecure. Give the target a scheme when you do: a schemeless target is treated as http://, where skipping verification does nothing, and Swapbook will say the flag was ignored rather than pretend it worked.

Headless check (CI)

swapbook check --target :8080 renders every story and variant once and exits non-zero if any fail, so a build can gate on it. It reports one line per variant and fails on an unreachable target, a missing adapter, a non-2xx preview, or an empty render. A render + reachability smoke; visual-diff and a11y gating are tracked separately.

swapbook check --target :8080
  ok   Button · primary
  FAIL Card · empty: preview 500
  29 preview(s), 1 failed

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.

Visual regression // screenshot & diff

Beyond "does it render", Swapbook can catch changes to how a component looks. A small Playwright harness screenshots every variant and diffs it against a committed baseline, so an accidental swap in your CSS fails the build. Screenshots need a browser, so this is a harness (the binary stays dependency-free) rather than a binary command.

# compare against baselines (what CI runs)
docker run --rm swapbook-visual
# refresh baselines after an intended change
docker run --rm -v "$PWD/test/e2e/visual:/work/test/e2e/visual" swapbook-visual npm run test:visual:update

Baselines and the comparison run in a pinned Docker image (Playwright + Go) so local and CI renders are byte-identical. On a mismatch, CI uploads an interactive expected/actual/diff report. Full walkthrough in the visual regression guide.

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,flask,express}/, 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.