htmx: Build Interactive Web Apps with HTML · Hypermedia Foundations
Forms the htmx Way
Build a sign-up form with browser validation, server-side errors returned with 422, a loading state and a reset after success.
What you'll build
A newsletter sign-up form that behaves like a production form: the browser checks the email format, the server checks for duplicates and answers 422 with the form showing the error, the button is disabled while sending, and on success the form is replaced by a thank-you message.
The idea
- Put
hx-poston the<form>: htmx submits all fields onsubmit(button or Enter). The browser's validation (required,type="email") runs first. - The server must validate again. On errors, return the form with messages and status 422 Unprocessable Content; on success, return the result.
- htmx 2 doesn't swap 4xx responses by default. Allow 422 with a
responseHandlingrule in<meta name="htmx-config">(or use the response-targets extension). hx-disabled-elt="find button"disables the button while the request runs; an element withhtmx-indicatorshows progress.
The code
<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="/subscribe" hx-swap="outerHTML" hx-disabled-elt="find button">
<label>Email <input type="email" name="email" required placeholder="try [email protected]"></label>
<button>Subscribe</button> <span class="htmx-indicator">Sending...</span>
</form>
<!-- The pretend server: on a real site this part is your backend (Python, PHP, Node, Go...) -->
<script>
const form = (email = "", error = "") => `
<form hx-post="/subscribe" hx-swap="outerHTML" hx-disabled-elt="find button">
<label>Email <input type="email" name="email" required value="${server.escape(email)}"
${error ? 'aria-invalid="true" aria-describedby="email-error"' : ""}></label>
<button>Subscribe</button> <span class="htmx-indicator">Sending...</span>
${error ? `<p id="email-error" style="color:#b91c1c">${error}</p>` : ""}
</form>`;
server.post("/subscribe", (req) => {
const email = (req.body.email || "").trim().toLowerCase();
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return { status: 422, body: form(email, "Enter a valid email address.") };
if (email === "[email protected]") return { status: 422, body: form(email, "This email is already subscribed.") };
return `<p>Thanks! Check <strong>${server.escape(email)}</strong> to confirm your subscription.</p>`;
}, { delay: 700 });
</script>
The server builds the form with one function for both the empty and the error state, which is exactly how you'd use a template partial on a real backend.
Common mistakes
- Returning errors with status 200 "to make htmx swap them". It works, but monitoring, tests and caches can't tell
success from failure. Use 422 and configure
responseHandling. - Dropping the user's input when re-rendering the form: always put the submitted values back.
- Relying only on browser validation: anyone can send a request without your page.
- Forgetting accessibility: link the message with
aria-describedbyand mark the fieldaria-invalid.
Key points
- Browser validation first, server validation always.
- 422 + a
responseHandlingrule lets htmx swap error forms. - Disable the button during the request to prevent double submits.
Further reading: htmx docs: response handling · hx-disabled-elt · Tutorial: Forms and validation
Practice questions
Why doesn't htmx 2 show your 422 error form by default, and how do you fix it?
Answer
htmx 2 doesn't swap 4xx/5xx responses. Add a responseHandling rule {code: "422", swap: true} before the error rule, or use the response-targets extension.
How do you stop users from submitting a form twice while the request runs?
Answer
hx-disabled-elt="find button" (or "this" on a button) disables it until the request finishes; hx-sync can also drop duplicate requests.
What must an error response include besides the message?
Answer
The values the user typed, so nothing is lost, plus accessible markup (aria-invalid, aria-describedby) linking the field to its error.
Open in the app to mark lessons complete, take notes and track your progress.