Skip to content

Repository files navigation

Swapbook

Storybook for htmx & hypermedia. One binary, zero JS toolchain.

Quickstart · Demos · Docs · How it works · Protocol · Examples · Roadmap


Ever needed to see what your htmx partial looks like in its error state, the one that only renders after a 422? Or its empty state, or the "a row just got added" state? In a running app you either trigger each one for real, or fake a broken backend and remember to undo it.

Swapbook renders those states in isolation. Point the binary at your running app and it previews any component in any state through your real htmx, with mock responses so you can hit the error swap without touching your backend, plus an inspector that logs every request and swap.

It is framework-agnostic: the binary speaks a tiny HTTP protocol, so it works with Go/templ, Django, Rails, Laravel, Flask, Express, or a plain server, with no per-framework requirement and no Node build step.

swapbook --target :8080
# open http://localhost:7007/__sb/

The Swapbook workbench: a story sidebar, a live component preview, and an htmx-aware inspector showing a mocked request's full lifecycle (mock, beforeSwap, afterSwap, 200, timing).

Install

# prebuilt binary (macOS / Linux)
curl -fsSL https://raw.githubusercontent.com/Aejkatappaja/swapbook/main/install.sh | sh

# or run without installing (Node)
npx swapbook --target :8080

# or with Go
go install github.com/Aejkatappaja/swapbook/cmd/swapbook@latest

Prebuilt binaries for macOS, Linux and Windows (amd64 / arm64) are attached to every release.

Why

Storybook is built around JS components; its HTML/Server modes are awkward and drag in a JS toolchain, the opposite of why you reached for htmx. The good server-rendered tools that exist are locked to one framework: Lookbook (Rails), phoenix_storybook (LiveView), django-lookbook (Django).

Swapbook fills the gap: a single tool for any hypermedia backend, with the parts a hand-rolled /preview page can't give you.

  • One binary. Static assets embedded. No node_modules, no config file.
  • htmx-aware inspector. See every request, its params, status, timing, the swap target highlighted in the preview, and the returned HTML.
  • Three interaction modes. mock (serve canned responses, no auth/DB), safe (real requests, mutations blocked), live (everything real).
  • Live controls. Edit a component's props from the toolbar and re-render.
  • a11y lint, search, deep-links, keyboard-driven.

The gallery UI is plain JavaScript, type-checked with JSDoc and checkJs in CI, not TypeScript: the whole tool is zero-build, so a compile step would defeat the point. The binary itself is Go with no dependencies.

Quickstart

Go / templ

Add the adapter and register your components:

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

func Workbench() http.Handler {
    reg := adapter.New()
    reg.HTMXSrc = "/static/htmx.min.js" // your app's htmx
    reg.CSSSrc = "/static/app.css"       // injected into bare-fragment previews
    reg.JSSrc = "/static/app.js"

    reg.RegisterIn("forms", "Workout Form",
        adapter.Var("new", WorkoutForm(user, store.Workout{}, "", "/app/workouts", "new")),
        // live controls: edit props from the UI
        adapter.VarC("controls", []adapter.Control{
            {Name: "heading", Type: "text", Default: "new workout"},
            {Name: "errMsg", Type: "text", Default: ""},
        }, func(a adapter.Args) adapter.Renderer {
            return WorkoutForm(user, store.Workout{}, a.String("errMsg"), "/app/workouts", a.String("heading"))
        }),
        // mock a route so interactions run with no auth/DB
        adapter.Var("empty", WorkoutForm(...)).
            Mock("GET /app/workouts/entry-row", EntryRow(store.WorkoutEntry{})),
    )
    return reg.Handler()
}

Mount it (dev only) and run Swapbook against your app:

mux.Handle(adapter.MountPath+"/", http.StripPrefix(adapter.MountPath, Workbench()))
swapbook --target :8080

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

Django

from swapbook_adapter import Registry, control, variant

reg = Registry(css_src="/static/app.css")
reg.register("Button", group="actions", variants=[
    variant("primary", lambda a: render_button("Save", "primary")),
    variant("controls", lambda a: render_button(a["label"], a["variant"]), controls=[
        control("label", "text", "Save"),
        control("variant", "select", "primary", options=["primary", "secondary"]),
    ]),
])

urlpatterns = reg.urls  # mounts /_swapbook/*

A variant is any callable (args) -> html str, so plain templates, django-components and cotton all work. Django 3.2+.

Rails

require_relative "swapbook"

REG = Swapbook::Registry.new(css_src: "/assets/app.css")
REG.register("Button", group: "actions", variants: [
  Swapbook.variant("primary", ->(a) { render_button("Save", "primary") }),
  Swapbook.variant("controls", ->(a) { render_button(a["label"], a["variant"]) }, controls: [
    Swapbook.control("label", default: "Save"),
    Swapbook.control("variant", type: "select", default: "primary", options: %w[primary secondary]),
  ]),
])

# in config/routes.rb:  mount REG => "/_swapbook"

A variant's render is a proc (args) -> HTML, so ActionView partials, ViewComponent and Phlex all work. Rails 6+.

Laravel / Blade

require 'swapbook.php';

$sb = new Swapbook();
$sb->cssSrc = '/css/app.css';
$sb->register('Button', [
    sb_variant('primary', fn($a) => view('button', ['variant' => 'primary'])->render()),
    sb_variant('controls', fn($a) => view('button', $a)->render(), [
        sb_control('label', 'text', 'Save'),
        sb_control('variant', 'select', 'primary', ['primary', 'secondary']),
    ]),
], 'actions');

// route all of /_swapbook/* to:  $sb->handle($method, $path, $query)

Flask

from swapbook import Registry, control, variant

reg = Registry(css_src="/static/app.css")
reg.register("Button", group="actions", variants=[
    variant("primary", lambda a: render_button("Save", "primary")),
    variant("controls", lambda a: render_button(a["label"], a["variant"]), controls=[
        control("label", "text", "Save"),
        control("variant", "select", "primary", options=["primary", "secondary"]),
    ]),
])

app.register_blueprint(reg.blueprint)  # mounts /_swapbook/*

A variant is any callable (args) -> html str, so Jinja templates and plain strings both work. Flask 2+.

Express

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

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

app.use(reg.router());  // mounts /_swapbook/*

A variant's render is (args) -> HTML string, so any template engine works. Requires express.

Web Components

Any custom element renders as-is: return the element's HTML (plus its script tag) from a variant. The demos use @aejkatappaja/phantom-ui loaded from a CDN.

Any other stack

There is no adapter requirement. Answer four HTTP endpoints and you are done, in any language. examples/{python,node,ruby}/ are dependency-free stdlib targets that implement the protocol by hand. See SPEC.md.

examples/smoke.sh   # boots Python/Node/Ruby targets behind Swapbook and checks them

Try the demos

Every stack ships a runnable demo of the same component design system, so you can see Swapbook drive each one. Bring them all up with Docker:

docker compose -f examples/docker-compose.yml up --build
swapbook --target :8000            # then point the binary at any target
Stack Container port Demo
Django :8000 full design system
Rails :8001 full design system
Go :8002 full design system
Laravel :8003 full design system
Python / Node / Ruby :9101 / :9102 / :9103 stdlib subset

Or run a single stdlib target with no Docker: go run ./examples/go (then swapbook --target :8000).

How it works

Swapbook runs as a transparent reverse proxy in front of your app, with its UI mounted under /__sb/. Any request that isn't part of the UI is proxied to your app unchanged, so htmx requests fired from a preview reach your app same-origin, with no CORS and no URL rewriting. It also strips X-Frame-Options / frame-ancestors from proxied responses so previews can be framed.

The protocol is four endpoints on your app under /_swapbook:

Endpoint Purpose
GET /manifest.json stories, variants, control schemas, asset hints
GET /preview/{id}/{variant} render a component (accepts control args)
GET /mocks/{id}/{variant} list a variant's mocked routes
ANY /mock/{id}/{variant}/{i} render a mock response

Only the first two are required. Full details in SPEC.md.

Documentation

Full guides live in docs/guide/:

The protocol spec documents the four endpoints an app implements. Want to help? See CONTRIBUTING.md.

Compared to

Swapbook Storybook (server) Lookbook phoenix_storybook
Framework-agnostic partial Rails LiveView
No JS toolchain
Single binary
htmx-aware inspector
Mocked interactions via MSW

Roadmap

Shipped: protocol · mock/safe/live modes · htmx inspector (with Turbo, Unpoly and Datastar probes, verified end to end) · error-status mocks · controls · response viewer · a11y lint · search · deep-links · keyboard nav · copy-as-curl · swap-target and out-of-band highlight · SSE / WebSocket inspector · canvas background toggle · custom viewports · auth header injection for live-mode previews · headless check for CI · visual regression · play functions (scripted interactions + assertions) · per-component autodocs · auto-reload · install via curl / npx / go install.

Planned: Homebrew tap. Tracked in the roadmap.

Status

Early. The protocol is proven across nine stacks with tests: Go/templ, Django, Rails, Laravel, Flask and Express via adapters, plus dependency-free Python, Node and Ruby targets. Interfaces may still shift before a tagged release.

Limitations

Swapbook is early and deliberately scoped. Known edges:

  • 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 used when htmxSrc is unset is htmx 2.0.4. The Turbo, Unpoly and Datastar inspector 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 to your app in mock and safe mode; only mutations are blocked. Mock those routes, or run against 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: unlike an HTTP request, 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 multi-step stateful flows are out of scope.
  • Auth is global. --header injects the same credential into every request forwarded to the target, so a session runs as one identity; there is no per-story auth. Use a throwaway dev session (it lands in your shell history), never a production token.
  • CSS. Bare-fragment previews load your app's declared cssSrc. If your styles are code-split or purged per route, point cssSrc at the full stylesheet so previews match the app. Full-page components bring their own.
  • Relative asset paths inside a bare fragment may not resolve; prefer app-absolute paths like /static/....

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

License

MIT.

About

Storybook for htmx & hypermedia. One binary points at your running app and renders your components in isolation, with an htmx-aware inspector, live controls and mocked interactions. Works with Go, Django, Rails, Laravel, or any stack. Zero JS toolchain.

Topics

Resources

Contributing

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages