soul.demarkus.io:6309/demarkus-library/adr/0003-htmx-ssr-philosophy.md/v2 draft reader meta

ADR 0003 — SSR-first, htmx-hard, no JSON

Status: accepted (2026-06-10). The load-bearing front-end principle for demarkus-library. Deliberately strict: we are pushing the hypermedia approach as far as it goes and treating every escape hatch as a tracked concession.

Decision

The application is hypermedia-driven (HATEOAS). The server renders HTML; htmx is the only general interaction layer; there is no JSON API and no client-side application state. This is not "SSR with some htmx" — it is htmx-hard: the default answer to any interaction is "return server-rendered HTML and swap it in."

The rules (hard)

  1. The server owns all rendering. Every response is HTML — a full page or a fragment built by html/template. The browser receives finished markup, never data to assemble. goldmark→bluemonday→template is the only render path.
  2. No JSON tier, ever. Handlers return HTML via c.Render/c.HTML, never c.JSON. No REST/JSON projection, no GraphQL, no serialized view models crossing the wire. (Health check returns plain text; that's the lone non-HTML response and it's not application data.)
  3. No client-side state. Application state lives on the server (session + the world). The DOM is the only client state, and the server is its source of truth. No stores, no client router, no reactivity layer.
  4. Interactivity is hx-* attributes. Navigation (hx-boost), search (hx-get debounced), version/panel swaps, pagination, forms (hx-post → PUBLISH) — all are htmx returning server-rendered fragments into a target. Reach for an attribute before a line of JS.
  5. Self-hosted assets. htmx is vendored in-repo and served from the Go binary (/static/htmx.min.js, pinned to the latest htmx — currently 2.0.10) — not a CDN. Single binary, no runtime dependency, no third-party origin, version pinned and reviewable. Keep it current: bump to the latest htmx release as they ship. (Embodied 2026-06-10.)
  6. Fragments are first-class. Templates are factored so the inner content block can render standalone (for an htmx swap) or inside the page chrome (full load). A fragment request and a full navigation hit the same handler and the same template — no duplicate rendering paths.
  7. Degrade without JS. Because the server renders every route in full, hx-boost navigation and form posts work with JS disabled. htmx is enhancement, never the renderer. Keep it that way: no interaction may be only reachable through htmx.

How far we push it (the experiment)

We will attempt the entire app as hypermedia before admitting any island:

  • Catalog search (LOOKUP): debounced hx-get, results as an HTML <table> to click through. No JSON, no client filtering.
  • Editions (VERSIONS) + citations (backlinks): fragment swaps into a panel.
  • The cataloging desk (writes): form hx-post → PUBLISH; live preview via hx-post to a server render endpoint that returns rendered HTML — the server previews, not a client markdown lib.
  • The AI librarian (Phase 4): streaming via SSE (hx-ext="sse") — a token stream and a live tool-call trace, still server-driven HTML over the wire.

JS islands — last resort, tracked

A real island (custom JS beyond htmx + its official extensions) is allowed ONLY when hypermedia genuinely cannot do the job, and each one is recorded as a concession in this ADR with the reason. Pre-agreed candidates from the plan:

  • CodeMirror — the editor textarea needs real code-editing affordances (Phase 3).
  • Canvas graph — force-directed citation graph (post-v1).

Anything else must clear the bar: "this interaction is impossible or unacceptable as server HTML + htmx." Prefer an htmx extension or a tiny _hyperscript snippet over bespoke JS before conceding an island. No island is added silently — it gets a line here and a log/comment at the site.

Consequences

  • No Node build, no bundler, no SPA framework — fits demarkus's single-Go-binary ethos and makes Phase 5's shareable, server-rendered links free.
  • The win is conceptual surface area: one rendering path, one source of truth, no client/server state sync. The risk is hitting an interaction htmx strains on — the island rule is the pressure valve, kept deliberately narrow so we actually learn where the ceiling is.
  • .coderabbit.yaml enforces this on the web adapter: flag any c.JSON, any client-side framework, any CDN asset reference, any island added without an ADR line.

Vendored htmx version

  • 2.0.10 (2026-06-10) — current latest; initial vendor. (Briefly 2.0.4 the same day before bumping to latest.)
trail
  1. soul.demarkus.io:6309 v2