HTMX Project — Build a Complete Application with HTMX
In this tutorial, you'll learn to build a complete HTMX project. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
Build a complete HTMX-powered contact management dashboard that combines attributes, targets, triggers, swapping, history, indicators, and extensions into a fully functional single-page application.
What You'll Learn
By the end of this tutorial, you'll have built a contact manager with search, add/edit/delete, pagination, polling for real-time updates, history support, loading indicators, and client-side validation with Hyperscript.
Why It Matters
A complete project teaches how HTMX features compose together. Understanding how targets, triggers, swapping, and synchronization work in a real app prepares you to build production HTMX applications.
Real-World Use
The pattern in this project mirrors Durga Antivirus Pro's contact management interface for security incident reporting. The same HTMX patterns handle search, filtering, and CRUD operations on thousands of incident records.
Where This Fits in Your Learning Path
flowchart LR
A["Extensions & Hyperscript"] --> B["**HTMX Project**"]
B --> C["Production HTMX Apps"]
style B fill:#3b82f6,stroke:#2563eb,color:#fff
style A fill:#e2e8f0,stroke:#94a3b8
style C fill:#e2e8f0,stroke:#94a3b8
Project Structure
The contact manager consists of:
- Search bar with live filtering
- Contact list with pagination
- Add/edit/delete operations
- Real-time polling for updates
- Loading indicators
- URL history tracking
- Client-side form validation
Step 1: Main Dashboard Layout
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Manager</title>
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.x.x/dist/htmx.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/hyperscript.org@0.x.x/dist/_hyperscript.min.js"></script>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f8fafc; }
.card { background: white; border-radius: 8px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
input, select, button { padding: 8px 12px; border: 1px solid #e2e8f0; border-radius: 6px; font-size: 14px; }
button { background: #3b82f6; color: white; border: none; cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #e2e8f0; }
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
</style>
</head>
<body>
<div hx-ext="class-tools">
<div class="card" hx-get="/api/contacts" hx-trigger="load" hx-target="#contact-table" hx-indicator="#main-loader">
<h1>Contact Manager</h1>
<div id="main-loader" class="htmx-indicator">Loading contacts...</div>
<!-- Search and Add -->
<div class="card" style="display:flex;gap:8px;align-items:center">
<input type="search" name="q" placeholder="Search contacts..."
hx-get="/api/contacts" hx-trigger="input changed delay:300ms, search"
hx-target="#contact-table" hx-push-url="true" hx-indicator="#search-loader">
<span id="search-loader" class="htmx-indicator" style="font-size:12px;color:#666">Searching...</span>
<button hx-get="/api/contacts/new-form" hx-target="#contact-form" hx-swap="innerHTML"
style="margin-left:auto">Add Contact</button>
</div>
<div id="contact-form"></div>
<div id="contact-table">
<p>Loading contacts...</p>
</div>
</div>
</div>
</body>
</html>
Step 2: Contact List Template (Server Response)
<!-- /api/contacts response -->
<div classes="add .fade-in :load">
<table>
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Phone</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#contacts}}
<tr id="contact-{{id}}">
<td>{{name}}</td>
<td>{{email}}</td>
<td>{{phone}}</td>
<td>
<button hx-get="/api/contacts/{{id}}/edit"
hx-target="#contact-form"
hx-swap="innerHTML">Edit</button>
<button hx-delete="/api/contacts/{{id}}"
hx-target="#contact-{{id}}"
hx-swap="delete swap:200ms"
hx-confirm="Delete this contact?"
hx-indicator="#del-loader-{{id}}">Delete</button>
<span id="del-loader-{{id}}" class="htmx-indicator">Deleting...</span>
</td>
</tr>
{{/contacts}}
</tbody>
</table>
<!-- Pagination -->
<div style="display:flex;gap:8px;margin-top:16px">
{{#hasPrev}}
<button hx-get="/api/contacts?page={{prevPage}}&q={{query}}"
hx-target="#contact-table"
hx-push-url="true">Previous</button>
{{/hasPrev}}
<span>Page {{currentPage}} of {{totalPages}}</span>
{{#hasNext}}
<button hx-get="/api/contacts?page={{nextPage}}&q={{query}}"
hx-target="#contact-table"
hx-push-url="true">Next</button>
{{/hasNext}}
</div>
<!-- Real-time polling for updates -->
<div hx-get="/api/contacts/count"
hx-trigger="every 30s"
hx-target="#contact-count"
hx-swap="innerHTML">
<span id="contact-count"></span>
</div>
</div>
Step 3: Add/Edit Form Template
<!-- /api/contacts/new-form response -->
<div class="card" id="edit-form">
<h3>Add Contact</h3>
<form hx-post="/api/contacts"
hx-target="#contact-table"
hx-swap="beforeend"
hx-on::after-request="this.closest('#edit-form').remove()"
_="on submit
if #new-name.value is empty
halt the default
put 'Name is required' into #form-error
end
if #new-email.value is not *@*
halt the default
put 'Valid email required' into #form-error
end">
<div style="display:flex;flex-direction:column;gap:8px">
<input id="new-name" name="name" placeholder="Full Name" required>
<input id="new-email" name="email" type="email" placeholder="Email" required>
<input name="phone" placeholder="Phone">
<div id="form-error" style="color:#ff6b6b;font-size:12px"></div>
<div style="display:flex;gap:8px">
<button type="submit" hx-indicator="#save-loader">Save</button>
<button type="button" _="on click remove #edit-form">Cancel</button>
<span id="save-loader" class="htmx-indicator">Saving...</span>
</div>
</div>
</form>
</div>
Step 4: Server Integration (Express.js"Express" >}}.js Example)
const express = require('express')
const app = express()
app.use(express.urlencoded({ extended: true }))
let contacts = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', phone: '555-0101' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', phone: '555-0102' }
]
let nextId = 3
// List with search and pagination
app.get('/api/contacts', (req, res) => {
const q = (req.query.q || '').toLowerCase()
const page = parseInt(req.query.page) || 1
const limit = 5
let filtered = contacts
if (q) filtered = contacts.filter(c => c.name.toLowerCase().includes(q) || c.email.toLowerCase().includes(q))
const totalPages = Math.ceil(filtered.length / limit)
const paged = filtered.slice((page - 1) * limit, page * limit)
let html = '<table><thead><tr><th>Name</th><th>Email</th><th>Actions</th></tr></thead><tbody>'
paged.forEach(c => {
html += `<tr id="contact-${c.id}">
<td>${c.name}</td>
<td>${c.email}</td>
<td>
<button hx-delete="/api/contacts/${c.id}" hx-target="#contact-${c.id}" hx-swap="delete" hx-confirm="Delete?">Delete</button>
<button hx-get="/api/contacts/${c.id}/edit" hx-target="#contact-form" hx-swap="innerHTML">Edit</button>
</td>
</tr>`
})
html += '</tbody></table>'
html += '<div>Page ' + page + ' of ' + totalPages
if (page > 1) html += `<button hx-get="/api/contacts?page=${page-1}" hx-target="#contact-table">Previous</button>`
if (page < totalPages) html += `<button hx-get="/api/contacts?page=${page+1}" hx-target="#contact-table">Next</button>`
html += '</div>'
res.send(html)
})
app.post('/api/contacts', (req, res) => {
const contact = { id: nextId++, name: req.body.name, email: req.body.email, phone: req.body.phone || '' }
contacts.push(contact)
res.send(`<tr id="contact-${contact.id}">
<td>${contact.name}</td>
<td>${contact.email}</td>
<td>
<button hx-delete="/api/contacts/${contact.id}" hx-target="#contact-${contact.id}" hx-swap="delete">Delete</button>
</td>
</tr>`)
})
app.delete('/api/contacts/:id', (req, res) => {
contacts = contacts.filter(c => c.id != req.params.id)
res.send('') // empty response, swap="delete" removes the target
})
app.listen(3000)
Expected output: A fully functional contact manager with search, pagination, CRUD operations, and real-time updates.
Common Mistakes
1. Forgetting that HTMX expects HTML, not JSON
HTMX cannot parse JSON responses. Always return HTML fragments from the server.
2. Not returning an empty body for delete operations
When using hx-swap="delete", the response body is ignored. Return an empty 200 response.
3. Overusing polling
Polling every 30 seconds is usually sufficient. Faster polling wastes server resources.
4. Not handling empty search results
When search returns no results, show a helpful message instead of an empty table.
5. Forgetting to encode HTML entities in user input
User-generated content must be HTML-escaped to prevent XSS. Use template escaping or a library like he.
Practice Questions
Why does the search use hx-push-url? To update the URL with the search query, allowing bookmarking and back-button navigation.
How does the polling update the contact count? A separate div with hx-trigger="every 30s" fetches the count and updates the display.
What does hx-confirm do? It shows a browser confirm dialog before the request. Cancel prevents the request.
Why delete the edit form after successful save? The hx-on::after-request event removes the edit form div after the POST completes.
How does the "closest .card" target work? It finds the nearest ancestor with class "card", allowing the delete button to target its own card.
Challenge
Extend the project with sorting columns, bulk delete with checkboxes, email notifications via server events, and a contact detail view with hx-push-url for back-button support.
FAQ
What's Next
Congratulations on building a complete HTMX application! Continue learning:
| Tutorial | What You'll Learn |
|---|---|
| HTMX Advanced | WebSockets, SSE, and advanced patterns |
| HTMX and Alpine.js | Combining HTMX with Alpine for rich interactivity |
Related topics: progressive enhancement and hypermedia, hypermedia-driven REST APIs.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro