htmx-native Responses

Spec for making the submit endpoint speak HTML so an htmx front end can swap the response straight into the page. Today POST /api/v1/submit/{form} returns JSON ({"message":"ok"} / {"error":"…"}); htmx swaps a response body into a target element and, by default, only swaps 2xx. This is the cross-cutting piece the min-fill response fragments and the honeypot drop already lean on, and the natural completion of the htmx direction (target: 0.4.x).

The GET issuance endpoints (/form/{form}, /token/{form}) already return HTML. This plan is only about the POST submit response.

Backward compatibility — content negotiation

The relay has existing JSON consumers (the OpenAPI contract, curl, non-htmx fetch). Do not break them. The endpoint negotiates on the HX-Request header htmx sends on every request:

  • HX-Request: trueHTML fragment response (this plan).
  • otherwise → the exact current JSON behavior, unchanged.

HX-Request is htmx-specific and precise (unlike Accept: text/html, which a full-page <form> POST also sends). One code path branches on it at the point each response is built.

Decisions to confirm

These shape the build; recommendations given.

A. Status codes for htmx error responses

htmx (2.x) only swaps 2xx by default and ignores 4xx/5xx (fires htmx:responseError). Two ways to still show the user an error fragment:

  • A1 (recommended): keep correct HTTP status (422 validation, 422 min-fill expired, 400/404/500 as today) and return the HTML fragment; document that htmx pages load the response-targets extension (or set htmx.config.responseHandling) to swap error responses into an error target. Correct semantics; the OpenAPI status codes stay meaningful; modern-htmx idiom.
  • A2: return 200 for every htmx response (even validation/expired), body distinguishing success vs error. Swaps out of the box with no client config, but lies about HTTP status and muddies the API contract.

Recommendation A1. Note this also lets min-fill’s “expired” stay 422 (it was 200-only as a stopgap while there was no HTML mode); under A1 it becomes a 422 reload fragment swapped via response-targets.

B. How validation errors render (the main scope driver)

  • B1 (recommended v1): a compact error-list fragment — e.g. <ul class="form-errors"><li>Email is required</li>…</ul> — swapped into an error container. Needs form.Validate to return the set of failures (today it returns a single error); modest change. Does not touch the renderer.
  • B2 (later enhancement): re-render the whole form with inline per-field errors and repopulated values. The htmx-idiomatic “swap the form back.” Richer, but needs structured per-field errors and a renderer that accepts error text + prior values (a real render extension). Defer to its own plan; it also composes with the conditional-fields render path.

Recommendation B1 now, B2 as a follow-on. (If you want B2 first, say so — it roughly triples this plan.)

C. Success fragment

  • C1 (recommended): an optional per-form confirmation message rendered as the success fragment, defaulting to a generic Thank you. when unset:
    forms:
      contact:
        confirmation: "Thanks — we'll be in touch."
    Rendered as a small fragment (e.g. <div class="form-confirmation">…</div>), HTML-escaped. Parsed strictly + added to the JSON schema.
  • C2: a fixed generic success fragment, no config. Simpler, less useful.

Recommendation C1. The spam drops (honeypot, min-fill too-fast/forged) must return the byte-identical success fragment so a bot can’t distinguish a drop from a real success — same opacity rule as today’s JSON {"message":"ok"}.

Response catalogue (htmx branch, under A1 + B1 + C1)

OutcomeStatusFragment
success200the confirmation fragment
honeypot filled / min-fill too-fast / forged / missing token200the same confirmation fragment (opaque drop, no relay)
validation failure422the error-list fragment
min-fill expired422a “form expired — reload” fragment
unknown form / bad request404 / 400a minimal error fragment
relay failure (a fail output) / misconfig500a minimal error fragment

Non-htmx requests get the current JSON for every row (unchanged).

Implementation shape

  • A single response seam in handler.go: a helper that, given the request and a logical outcome (ok / validation-errors / expired / not-found / server-error), emits either the current JSON or the HTML fragment based on HX-Request. Every current jsonResponse(...) call site routes through it so behavior is uniform (and the min-fill/honeypot drops automatically pick up the confirmation fragment).
  • form.Validate returns the full failure set (B1) — today it stops at the first; collect all so the fragment lists them.
  • Config: optional per-form confirmation (C1), strict-parsed + schema $def.
  • Fragments are built with the same escaping the renderer uses; no user input is emitted unescaped.

Testability & phasing (red/green/verify) — ✅ done

All unit-testable at the handler/config level (no deploy needed). Delivered as three red/green/verify cycles, each with an independent adversarial review:

  • Phase 1 — negotiation seam + confirmation (C1/D): isHTMX on the HX-Request header; successResponse returns the confirmation fragment (default Thank you.) to htmx else the unchanged JSON; the honeypot and min-fill silent drops route through it too, so a drop is byte-identical to a real success. New per-form confirmation config + schema $def. Reviewed: escaping, drop opacity, non-htmx JSON byte-identical.
  • Phase 2a — form.ValidateAll (B1): collect-all validator returning one message per failing field in declaration order; Validate reimplemented as a first-message wrapper so every existing constraint test stays byte-identical.
  • Phase 2b — error fragments + seam (A1/B1): errorResponse + errorFragment (<ul class="form-errors">); validation → htmx list of all messages / non-htmx first-message JSON; min-fill expired and every other 4xx/5xx routed through the seam with real status codes preserved. Reviewed: no error site left as raw JSON for htmx, non-htmx unchanged, escaping.
  • Phase 3 — B2 inline re-render (follow-on, shipped): the htmx validation response was upgraded from the B1 <ul> list to a re-rendered form — a renderer value-repopulation primitive (RenderOptions.Values), per-field errors (form.ValidateFields + RenderOptions.Errors inline <p class="field-error"> with aria-invalid/aria-describedby), and the handler re-rendering the form (values + errors + a fresh min-fill token) on an htmx validation failure. The <ul> fragment remains for expired/404/500.
  • Docs: htmx-integration section in the reference (confirmation, the fragment table, the response-targets snippet); CHANGELOG.md.

Shipped under A1 (real status codes + response-targets client-side). This also retired min-fill’s interim 422-only expired note — expired is now a proper 422 error fragment for htmx, unchanged JSON otherwise. B2 (originally deferred) shipped as Phase 3 above, so validation re-renders the form inline.

Non-goals

  • Changing the JSON contract for non-htmx clients.
  • HX-Redirect-based success navigation — that’s the separate success-redirect item (hardening 3b); it can layer on later for full-page (non-swap) flows.