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.

Create a free account to save this page, take notes and track your progress. Sign in

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:

A form with browser validationTry it yourself
<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:

Server-side errors with a 422 responseTry it yourself
<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?

  1. htmx 2 doesn't swap 4xx responses unless responseHandling allows it
  2. 422 responses can't contain HTML
  3. The form needs hx-validate
  4. htmx 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.

Was this page helpful?