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 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:
| 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.
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. |
--header | Header injected into every request to the target, as Name: value. Repeatable, so components behind auth render in live mode. | |
--version | Print the version and exit. |
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/.
| 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 (1.x or 2.x); the embedded fallback is 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, but the inspector does not visualize their events.
- 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. If your styles are code-split or purged per route, pointcssSrcat the full stylesheet.