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.

Live demo โ†—

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 called hx-disabled-elt: the submit button is disabled while the request is in flight;
  • on success the same response fills #result and carries two <hx-partial hx-target="โ€ฆ" hx-swap="โ€ฆ"> elements that update #errors and 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:&lt;code&gt;</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>&lt;hx-partial&gt;</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)
	}
}

All examples