htmx Tutorial · Making Requests · Lesson 10 of 30
Forms and Validation
Submit forms with htmx, keep browser validation, show server-side errors with a 422 response, and reset the form after success.
Submitting a form
Put hx-post on the <form> itself. htmx sends all fields when the form is submitted (by the button or by pressing
Enter), and the browser's built-in validation (required, type="email", minlength) still runs first:
<form hx-post="/subscribe" hx-target="#msg" hx-on::after-request="if(event.detail.successful) this.reset()">
<input type="email" name="email" placeholder="[email protected]" required>
<button>Subscribe</button>
</form>
<p id="msg"></p>
<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
server.post("/subscribe", (req) => `Thanks! We'll write to <strong>${server.escape(req.body.email)}</strong>.`);
</script>
hx-on::after-request="if(event.detail.successful) this.reset()" clears the form after a successful request. (You'll
learn hx-on on the events page.)
Showing server-side errors
The server must always validate too: browser checks can be skipped. When the input is invalid, a good API answers 422 Unprocessable Content with the form again, showing the errors.
But htmx 2 doesn't swap 4xx responses by default. Tell it to swap 422 with htmx.config.responseHandling, set
here with a <meta> tag:
<meta name="htmx-config" content='{"responseHandling": [
{"code": "204", "swap": false},
{"code": "422", "swap": true},
{"code": "[23]..", "swap": true},
{"code": "[45]..", "swap": false, "error": true}
]}'>
Rules are checked in order and the first match wins. Now a 422 response replaces the form with the version that shows the errors:
<meta name="htmx-config" content='{"responseHandling": [{"code": "204", "swap": false}, {"code": "422", "swap": true},
{"code": "[23]..", "swap": true}, {"code": "[45]..", "swap": false, "error": true}]}'>
<form hx-post="/register" hx-swap="outerHTML">
<input name="username" placeholder="Username (try 'admin')">
<button>Register</button>
</form>
<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
server.post("/register", (req) => {
const name = (req.body.username || "").trim();
const error = !name ? "Please choose a username." : name === "admin" ? "That username is taken." : "";
if (!error) return `<p>Welcome, <strong>${server.escape(name)}</strong>!</p>`;
return { status: 422, body: `
<form hx-post="/register" hx-swap="outerHTML">
<input name="username" value="${server.escape(name)}" aria-invalid="true" aria-describedby="err">
<button>Register</button>
<p id="err" style="color:#b91c1c">${error}</p>
</form>` };
});
</script>
The other popular option is the official
response-targets extension, which adds attributes such as
hx-target-422="#errors".
Test yourself
Your server answers an invalid form with status 422 and the form showing errors, but nothing changes on the page. Why?
htmx 2 doesn't swap 4xx responses unless responseHandling allows it422 responses can't contain HTMLThe form needs hx-validatehtmx only swaps responses to GET requests
Show answer
Answer: htmx 2 doesn't swap 4xx responses unless responseHandling allows it
Add a responseHandling rule with swap: true for 422, or use the response-targets extension.