htmx 4: status routing and partials
hx-status:422 sends validation errors to their own region, 5xx bodies are dropped, and <hx-partial> updates a second region from one response.
htmx 4: status routing and partials
htmx 4 swaps every response, and hx-status:<code> decides per status
where it goes โ the built-in replacement for the htmx 2
response-targets extension. This example pins 4.0.0 in its own
ghtmx.json and puts the whole story on one form:
hx-status:422="target:#errors"โ validation problems answer 422 and land in the error region, not in#result;hx-status:5xx="swap:none"โ a server error's body is never swapped in (htmx 4 would otherwise show it);hx-disable="find button"โ htmx 4's name for what htmx 2 calledhx-disabled-elt: the submit button is disabled while the request is in flight;- on success the same response fills
#resultand carries two<hx-partial hx-target="โฆ" hx-swap="โฆ">elements that update#errorsand the "last signup" footer โ each names its own target and swap, the htmx 4 form of an out-of-band swap.
ghtmx generate && go run ./cmd # serves http://127.0.0.1:8087/signup
The status suffix (422, 5xx, 40x), the config keys inside the
value (target:, swap:, select:), and the partials' targets are
all validated against the pinned surface; under a 2.0.10 pin the
hx-status: attributes are reported as introduced in 4.0.0
(GHTMX-E0501).
status.ghtmx
package htmx4status
import "github.com/go-monolith/ghtmx/examples/htmx4-status/ghtmxgen"
templ page() {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>htmx 4: status routing and partials</title>
@ghtmxgen.HTMXScript()
@styleSheet()
</head>
<body>
<main class="app">
<span class="badge">ghtmx example ยท htmx 4</span>
<h1>Status routing and partials</h1>
<p class="tagline">htmx 4 swaps every response; <code>hx-status:<code></code> says where each status goes. A 422 lands in <code>#errors</code>, a 5xx body is dropped, and a 200 fills <code>#result</code> while an <code><hx-partial></code> in the same response updates the footer.</p>
<form hx-post={ Signup } hx-target="#result" hx-status:422="target:#errors" hx-status:5xx="swap:none" hx-disable="find button">
<label>email <input name="email" placeholder="you@example.com" autocomplete="off"/></label>
<label>handle <input name="handle" placeholder="no spaces" autocomplete="off"/></label>
<label class="check"><input type="checkbox" name="outage"/> answer with a 500 instead</label>
<button type="submit">Sign up</button>
</form>
<div id="errors"></div>
<div id="result" class="out"><p class="muted">Submit the form: an invalid signup answers 422, a valid one 200.</p></div>
<p class="footer">last signup: <strong id="last-signup">โ</strong></p>
</main>
</body>
</html>
}
// validationErrors is the 422 body; hx-status:422 routes it to #errors.
fragment validationErrors(problems []string) {
<ul class="errors">
for _, p := range problems {
<li>{ p }</li>
}
</ul>
}
// welcome is the 200 body for #result, plus two partials: one clears
// #errors, one updates the footer โ each names its own target and swap.
fragment welcome(handle string, email string) {
<p class="ok">Welcome, <strong>{ handle }</strong>. A confirmation is on its way to { email }.</p>
<hx-partial hx-target="#errors" hx-swap="innerHTML">
<p class="ok small">no problems</p>
</hx-partial>
<hx-partial hx-target="#last-signup" hx-swap="innerHTML">
{ handle }
</hx-partial>
}
ghtmx.json
{
"htmxVersion": "4.0.0",
"generatedPackage": { "dir": "examples/htmx4-status/ghtmxgen" }
}
status.go
// The htmx4-status example: htmx 4 swaps every response by default, and
// hx-status:<code> decides per status where it goes. A signup form
// routes its 422 validation errors to #errors, ignores 5xx bodies, and
// on success updates a second region through <hx-partial>, the htmx 4
// form of an out-of-band swap. The compiler validates the status
// suffixes, the config keys, and the partial's target against the
// 4.0.0 pin in ghtmx.json.
//
// Run it with:
//
// ghtmx generate && go run ./cmd
package htmx4status
import (
_ "embed"
"net/http"
"strings"
"github.com/go-monolith/ghtmx"
)
//go:embed status.css
var styleCSS string
// styleSheet inlines status.css into the page head. The rules live in
// their own file so the template shows markup, not presentation.
func styleSheet() ghtmx.Component {
return ghtmx.Raw("<style>" + styleCSS + "</style>")
}
func signupHome(w http.ResponseWriter, r *http.Request) {
if err := page().Render(r.Context(), w); err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
}
// validate returns the problems with a signup, in display order.
func validate(email, handle string) []string {
var problems []string
if !strings.Contains(email, "@") {
problems = append(problems, "email needs an @")
}
switch {
case handle == "":
problems = append(problems, "handle is required")
case strings.ContainsAny(handle, " \t"):
problems = append(problems, "handle cannot contain spaces")
}
return problems
}
// Signup answers with the status the outcome deserves and lets the
// markup route each one: 422 carries the error list (hx-status:422
// sends it to #errors), 500 carries a body nobody should see
// (hx-status:5xx swaps nothing), 200 carries the welcome plus a partial
// for the "last signup" region.
func Signup(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
if r.PostForm.Get("outage") != "" {
http.Error(w, "<p>simulated outage โ this body must not be swapped in</p>", http.StatusInternalServerError)
return
}
email := strings.TrimSpace(r.PostForm.Get("email"))
handle := strings.TrimSpace(r.PostForm.Get("handle"))
if problems := validate(email, handle); len(problems) > 0 {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnprocessableEntity)
if err := validationErrorsFragment(problems).RenderFragment(r.Context(), w); err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
return
}
if err := welcomeFragment(handle, email).RenderFragment(r.Context(), w); err != nil {
http.Error(w, "failed to render", http.StatusInternalServerError)
}
}
// Routes builds the example's router; the official docs site mounts
// it as a live demo.
func Routes() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /signup", signupHome)
mux.HandleFunc("POST /signup", Signup)
return mux
}
status.css
:root { --primary: #008391; --bg: #f5f6f7; --surface: #ffffff; --text: #1c1e21; --muted: #525860; --border: #dadde1; --error: #b3261e; --ok: #2e7d32; color-scheme: light dark; }
@media (prefers-color-scheme: dark) {
:root { --primary: #dbbc30; --bg: #1b1b1d; --surface: #242526; --text: #e3e3e3; --muted: #a5adba; --border: #444950; --error: #ff8a80; --ok: #81c995; }
}
* { box-sizing: border-box; }
body {
font-family: system-ui, sans-serif; margin: 0; min-height: 100vh;
background: var(--bg); color: var(--text);
display: flex; justify-content: center; align-items: flex-start;
}
.app {
width: min(40rem, 92vw); margin: 3rem 0; padding: 1.6rem 1.8rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: .6rem; box-shadow: 0 8px 24px rgba(0, 0, 0, .1);
}
.badge {
display: inline-block; font-size: .72rem; font-weight: 600;
color: var(--primary); border: 1px solid var(--primary);
border-radius: 999px; padding: .1rem .6rem; margin-bottom: .8rem;
}
h1 { margin: 0 0 .4rem; font-size: 1.5rem; }
.tagline { margin: 0 0 1.2rem; color: var(--muted); font-size: .95rem; }
form { display: grid; gap: .7rem; }
form label { display: flex; flex-direction: column; gap: .2rem; font-size: .85rem; color: var(--muted); }
form label.check { flex-direction: row; align-items: center; gap: .5rem; }
input:not([type="checkbox"]) {
font-size: 1rem; padding: .45rem .7rem; border-radius: .45rem;
border: 1px solid var(--border); background: var(--bg); color: inherit;
}
button {
font-size: .9rem; padding: .45rem .9rem; border-radius: .45rem; cursor: pointer;
border: 1px solid var(--primary); background: var(--primary); color: var(--surface);
justify-self: start;
}
button[disabled] { opacity: .5; cursor: progress; }
.errors { margin: .8rem 0 0; padding: .6rem 1rem .6rem 2rem; border-radius: .45rem; border: 1px solid var(--error); color: var(--error); }
.out { margin-top: 1rem; padding: .8rem 1rem; border-radius: .5rem; background: var(--bg); min-height: 3rem; }
.out p { margin: 0; }
.ok { color: var(--ok); }
.ok.small { margin: .8rem 0 0; font-size: .85rem; }
.muted { color: var(--muted); }
.footer { margin: 1rem 0 0; font-size: .85rem; color: var(--muted); }
cmd/main.go
// Command htmx4status serves the htmx4-status example standalone.
package main
import (
"fmt"
"net/http"
"os"
example "github.com/go-monolith/ghtmx/examples/htmx4-status"
)
func main() {
addr := "127.0.0.1:8087"
if v := os.Getenv("GHTMX_EXAMPLE_ADDR"); v != "" {
addr = v
}
fmt.Printf("Listening on http://%s/signup\n", addr)
if err := http.ListenAndServe(addr, example.Routes()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}