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

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

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.