Creating dynamic, responsive web forms doesn’t have to be complicated. With HTMX, you can build a seamless signup form using just a few HTML enhancements. In this blog, I’m going to show how to make a basic signup form with HTMX.
We’ll cover how a few minor improvements to HTML allow us to make a simple form quickly while keeping state consistent. There will be only three sections to the form, but it’s easily extendable with changes in only one or two routes per change.
What Is HTMX?
HTMX extends HTML by adding attributes that enable HTTP requests from any element—not just forms or links. This expands your options beyond GET
and POST
methods, allowing you to use all HTTP methods. It also lets you update targeted parts of the DOM rather than reloading the entire page. This results in more declarative and maintainable HTML.
Now that we’ve covered what HTMX is and how it enhances HTML, let’s dive into building our signup form. We’ll break the process into three simple steps: collecting basic information, selecting approved products, and confirming the signup. Each step will demonstrate how HTMX keeps things clean and efficient.
First Page: Collecting Basic Info
Our first page will use a form to gather some basic information: name and wealth. This form leverages HTMX’s hx-post
attribute to send a POST request to our /initial
endpoint when the user clicks Submit. The response will replace the entire form, creating a smooth, seamless experience.
By handling everything within a single page and updating only the necessary content, this approach mimics the developer experience of a modern single-page application — without the added complexity of a frontend framework.
<form hx-post="/initial" hx-swap="outerHTML"> <label for="name">Name:</label> <input id="name" name="name" type="text" /> <label for="wealth">Wealth:</label> <input id="wealth" name="wealth" type="number" value="0.00" step="0.01" /> <button type="submit">Submit</button> </form>
Second Page: Selecting Approved Products
After the initial form submission, our server will:
- Perform basic validation.
- Build a list of approved products based on the customer’s provided wealth.
- Save the customer information.
- Return a new form for product selection.
By using HTMX, we keep all state management in the backend. This will minimize complexity and reduce the need for frontend caching or state logic often required by modern frameworks.
let products = []; if(wealth > 0) products.push('checking'); if(wealth > 100) products.push('savings'); if(wealth > 10000) products.push('investing'); customers.push({ id: newid, name, wealth, products }); let html = ['<html><body>']; html.push('<form hx-post="/products" hx-swap="outerHTML">'); html.push(`<p>${name}, which products do you want?</p>`); html.push(`<input hidden name="id" value="${newid}" />`); for(let i = 0; i < products.length; i++) { const p = products[i]; html.push(`<input id="${p}-${i}" type="checkbox" name="${p}" />`); html.push(`<label for="${p}-${i}">${p}</label>`); html.push('<br />'); } html.push('<button type="submit">Submit</button>'); html.push('</form>'); html.push('</body></html>'); res.send(html.join('\n'));
Final Page: Confirming the Sign-Up
The /products
endpoint will save the customer’s chosen products and return a simple “Thank You” message. While there’s not much to display at this stage, this is an ideal place to implement additional actions like:
- Sending a confirmation email to the customer.
- Notifying internal teams or employees.
- Triggering follow-up workflows for onboarding or customer engagement.
By keeping this logic server-side and leveraging HTMX’s lightweight updates, you maintain a streamlined, maintainable architecture.
Conclusion
In this guide, we’ve demonstrated how HTMX can simplify the process of building a multi-step signup form using just a few declarative attributes and minimal server-side rendering. By leveraging HTMX, we achieved a smooth, single-page experience without the complexity of a full frontend framework.
This lightweight approach keeps your HTML clean, your backend logic centralized, and your application easier to maintain — all while delivering a seamless user experience.
If you’re looking for a simple yet powerful way to enhance your web applications, HTMX is an excellent tool to explore. And if you’re looking for other ways to enhance your applications, the Keyhole Dev Blog is a great place to start. Thanks for reading!
Complete Code
const express = require('express'), body_parser = require('body-parser'), app = express(); app.set('etag', false); app.use(express.static('public')); app.use(body_parser.urlencoded({ extended: false })); function makeIds() { let n = 0; return () => n++; } const customerIds = makeIds(); let customers = []; app.get('/', (req, res) => { res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <title>My Business</title> <meta name="description" content="index" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="color-scheme" content="dark" /> <script src="https://unpkg.com/[email protected]"></script> </head> <body> <h1>Sign Up Form</h1> <p>Please enter your information for a quote</p> <form hx-post="/initial" hx-swap="outerHTML"> <label for="name">Name:</label> <input id="name" name="name" type="text" /> <label for="wealth">Wealth:</label> <input id="wealth" name="wealth" type="number" value="0.00" step="0.01" /> <button type="submit">Submit</button> </form> <hr /> <h2>Customers</h2> <button hx-get="/customers" hx-target="#customer-list">Update</button> <div id='customer-list'></div> </body> </html>`); }); app.get('/customers', (req, res) => { let html = ['<html><body>']; html.push('<ul>'); for(let i = 0; i < customers.length; i++) { const c = customers[i]; html.push( `<li>${c.name} - ${c.wealth}` + `- ${c.products?.join(',') ?? 'none'} - ` + `${c.chosen?.join(',') ?? 'none'}</li>`); } html.push('</ul>'); html.push('</body></html>'); res.send(html.join('\n')); }); app.post('/initial', (req, res) => { const name = req.body.name, wealth = parseFloat(req.body.wealth), newid = customerIds(); if(wealth <= 0) { customers.push({ id: newid, name, wealth }); return res.send(` <html> <body> <p>You don't qualify for any of our products.</p> <a href="/">Home</a> </body> </html>`); } let products = []; if(wealth > 0) products.push('checking'); if(wealth > 100) products.push('savings'); if(wealth > 10000) products.push('investing'); customers.push({ id: newid, name, wealth, products }); let html = ['<html><body>']; html.push('<form hx-post="/products" hx-swap="outerHTML">'); html.push(`<p>${name}, which products do you want?</p>`); html.push(`<input hidden name="id" value="${newid}" />`); for(let i = 0; i < products.length; i++) { const p = products[i]; html.push(`<input id="${p}-${i}" type="checkbox" name="${p}" />`); html.push(`<label for="${p}-${i}">${p}</label>`); html.push('<br />'); } html.push('<button type="submit">Submit</button>'); html.push('</form>'); html.push('</body></html>'); res.send(html.join('\n')); }); app.post('/products', (req, res) => { const id = parseInt(req.body.id), chosechecking = req.body.checking === 'on', chosesavings = req.body.savings === 'on', choseinvesting = req.body.investing === 'on'; for(let i = 0; i < customers.length; i++) { if(customers[i].id !== id) continue; customers[i].chosen = []; if(chosechecking) customers[i].chosen.push('checking'); if(chosesavings) customers[i].chosen.push('savings'); if(choseinvesting) customers[i].chosen.push('investing'); break; } res.send(` <html> <body> <p>Thank you for signing up</p> <a href="/">Home</a> </body> </html>`); }); app.listen(8080, () => { console.log('waiting for connection http://localhost:8080/'); });