Skip to content

HTMX Project — Build a Complete Application with HTMX

DodaTech Updated 2026-06-28 6 min read

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

  1. Why does the search use hx-push-url? To update the URL with the search query, allowing bookmarking and back-button navigation.

  2. How does the polling update the contact count? A separate div with hx-trigger="every 30s" fetches the count and updates the display.

  3. What does hx-confirm do? It shows a browser confirm dialog before the request. Cancel prevents the request.

  4. Why delete the edit form after successful save? The hx-on::after-request event removes the edit form div after the POST completes.

  5. 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

Can this project work without a server?

For testing, use a mock server or the htmx.org/test-support script that simulates server responses.

How do I add authentication to this project?

Use server-side session management. HTMX respects cookies. Return 401 responses for unauthenticated requests and handle with HX-Redirect header.

Can I use this with any backend language?

Yes. HTMX works with any backend that returns HTML: Python (Django, Flask), PHP (Laravel), Ruby (Rails), Go, Java, .NET.

How do I handle errors gracefully?

Use htmx:responseError event to show error toasts. Return error HTML with appropriate status codes from the server.

Is HTMX suitable for large applications?

Yes. Major companies use HTMX in production. It scales well because most logic is on the server, keeping the client lightweight.


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