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
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:
| mode | behavior |
|---|---|
mock | Declared mocks are served locally; unmocked mutating requests (POST/PUT/DELETE/PATCH) are blocked. Full interaction, no auth, no DB. Default. |
safe | Real requests hit your app, but mutating requests are blocked. Preview against real read data. |
live | Everything 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
| flag | default | description |
|---|---|---|
--target | :8080 | Address of the app to proxy. Accepts :8080, localhost:8080 or a full URL. |
--port | 7007 | Port the Swapbook UI is served on. |
--host | 127.0.0.1 | Address 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. |
--header | Header injected into every request to the target, as Name: value. Repeatable, so components behind auth render in live mode. | |
--insecure | Skip 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. | |
--version | Print the version and exit. |
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.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.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/.
| stack | mount |
|---|---|
| Go | mux.Handle(adapter.MountPath+"/", ...) |
| Django | urlpatterns = reg.urls |
| Rails | mount REG => "/_swapbook" |
| Laravel / PHP | route /_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:
| endpoint | purpose |
|---|---|
GET /manifest.json | stories, 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:
- htmx is the verified path, any version. Previews load your app's own htmx via
htmxSrc, so they run whatever version you ship. The inspector carries one probe per generation, picked fromhtmx.version: 1.x / 2.x, and htmx 4, which renamed every event and moved tofetch(). The embedded fallback is still htmx 2.0.4. The Turbo, Unpoly and Datastar probes are best-effort; they are exercised against the real libraries in a browser end-to-end test, but htmx remains the most complete path. - Auto-triggering components. A preview with
hx-trigger="load"or polling (every 2s) fires real GET requests in mock and safe mode; only mutations are blocked. Mock those routes, or use a dev database. - SSE and WebSocket connections are proxied through and the inspector logs their lifecycle (open, each default message/frame with direction, close). They are observed, not gated: a streamed connection is never mocked or blocked, and named SSE events are not yet surfaced.
- Mocks are stateless. You can set a mock's status (200 by default, or a non-2xx like 422/500 to exercise error swaps), but it returns the same response on every call, so stateful multi-step flows are out of scope.
- Auth is global.
--headerinjects the same credential into every request forwarded to the target, so a session runs as one identity (no per-story auth). Use a throwaway dev session, never a production token. - CSS. Bare-fragment previews load your app's
cssSrc, which takes one path or a list of them in cascade order. If your styles are code-split or purged per route, declare every file or pointcssSrcat the full stylesheet. - TLS targets. An app behind a certificate nothing trusts (a local reverse proxy,
mkcert, a company CA) fails the handshake. Pass--insecureto skip verification, with the scheme on the target (https://app.localhost), since a schemeless target is treated ashttp://where the flag does nothing.