MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

HTMX: HTML-Driven Interactivity Without JavaScript

Use HTMX for hypermedia: attributes, AJAX, SSE, and server-driven UI patterns.

HTMXHTMLServer-DrivenFrontend

By MinhVo

Introduction

HTMX is a library that allows you to access modern browser features directly from HTML, without writing JavaScript. It extends HTML with attributes that enable AJAX requests, CSS transitions, WebSockets, and Server-Sent Eventsβ€”all through markup rather than code. HTMX represents a return to the original vision of the web: hypermedia-driven applications where the server renders HTML and the browser displays it.

In a world dominated by complex JavaScript frameworks like React, Vue, and Angular, HTMX offers a refreshing alternative. It eliminates the need for a separate frontend build step, complex state management libraries, and client-side routing. Instead, your server handles the logic and returns HTML fragments that HTMX swaps into the page. This dramatically simplifies the development process while maintaining rich, interactive user experiences.

HTMX is gaining significant traction in the developer community, with adoption by organizations that value simplicity and rapid development. Frameworks like Django, Rails, Laravel, and Spring Boot all have excellent HTMX integration patterns. In this guide, we'll explore HTMX's core concepts, implementation patterns, and production-ready techniques for building modern web applications with minimal JavaScript.

HTMX and modern web development

Understanding HTMX: Core Concepts

How HTMX Works

HTMX extends HTML with custom attributes that trigger AJAX requests. When an event occurs (click, submit, keyup, etc.), HTMX sends an HTTP request to a specified URL and swaps the response HTML into a target element on the page.

The core attributes are:

  • hx-get/post/put/delete β€” The HTTP method and URL to request
  • hx-trigger β€” The event that triggers the request
  • hx-target β€” Where to place the response HTML
  • hx-swap β€” How to insert the response (innerHTML, outerHTML, beforeend, etc.)
  • hx-swap-oob β€” Out-of-band swaps for updating multiple elements
<!-- Simple button that loads content -->
<button hx-get="/api/messages" hx-target="#messages" hx-swap="innerHTML">
  Load Messages
</button>
<div id="messages"></div>
 
<!-- Form that submits without page reload -->
<form hx-post="/api/search" hx-target="#results" hx-trigger="submit, keyup delay:300ms from:input">
  <input type="text" name="q" placeholder="Search...">
  <button type="submit">Search</button>
</form>
<div id="results"></div>
 
<!-- Infinite scroll -->
<div hx-get="/api/items?page=2" hx-trigger="revealed" hx-swap="afterend" hx-target="#item-list">
  Loading more...
</div>

The Hypermedia Philosophy

HTMX embraces the original hypermedia model of the web, where HTML is the primary application format. This contrasts with the Single Page Application (SPA) approach where JavaScript dominates and HTML becomes a thin shell for mounting components.

In the hypermedia model:

  • The server owns the application state and renders HTML
  • The browser handles display and user interaction
  • Navigation happens through links and form submissions
  • Partial page updates use HTMX's HTML fragment swaps

This approach reduces complexity because you don't need to synchronize client and server state. The server is always the source of truth, and the client simply displays what it receives.

HTMX vs JavaScript Frameworks

FeatureHTMXReact/Vue/Angular
Learning CurveHTML attributes onlyJSX/templates + JS ecosystem
Bundle Size~14KB40KB-200KB+
Build StepNone requiredWebpack/Vite/CLI required
State ManagementServer-sideClient-side (Redux, Vuex, etc.)
SEOExcellent (server-rendered)Requires SSR setup
Offline SupportLimitedFull (with service workers)
Complex UIsPossible but verboseNative strength
Developer ToolingMinimalExtensive

Hypermedia architecture

Architecture and Design Patterns

Server-Side Rendering with HTMX Fragments

The key architectural pattern with HTMX is returning HTML fragments rather than full pages. When HTMX makes a request, the server detects whether it's an HTMX request (via the HX-Request header) and returns either a full page or just the fragment.

# Django example with HTMX fragment detection
from django.shortcuts import render
from django.http import HttpResponse
 
def contact_list(request):
    contacts = Contact.objects.all()
    
    # If HTMX request, return just the fragment
    if request.headers.get('HX-Request'):
        return render(request, 'contacts/partials/list.html', {'contacts': contacts})
    
    # Full page for initial load
    return render(request, 'contacts/list.html', {'contacts': contacts})
<!-- contacts/partials/list.html (HTMX fragment) -->
<div id="contact-list">
  {% for contact in contacts %}
  <div class="contact-card">
    <h3>{{ contact.name }}</h3>
    <p>{{ contact.email }}</p>
    <button hx-delete="/api/contacts/{{ contact.id }}" 
            hx-target="#contact-list" 
            hx-confirm="Delete {{ contact.name }}?">
      Delete
    </button>
  </div>
  {% endfor %}
</div>

The Islands Architecture Pattern

HTMX works well with the "islands architecture" pattern where most of the page is static server-rendered HTML, with interactive "islands" enhanced by HTMX attributes. This gives you excellent performance because only the interactive parts require server communication.

<!-- Static page with HTMX-enhanced islands -->
<main>
  <!-- Static content - no server interaction needed -->
  <article>
    <h1>Product Review</h1>
    <p>This is a great product that solves many problems...</p>
  </article>
  
  <!-- Interactive island: like button -->
  <div hx-post="/api/products/123/like" hx-swap="outerHTML">
    <button>πŸ‘ Like (42)</button>
  </div>
  
  <!-- Interactive island: comments section -->
  <section hx-get="/api/products/123/comments" hx-trigger="load" hx-target="#comments">
    <div id="comments">Loading comments...</div>
  </section>
  
  <!-- Interactive island: add to cart -->
  <form hx-post="/api/cart/add" hx-target="#cart-status" hx-swap="innerHTML">
    <input type="hidden" name="product_id" value="123">
    <button type="submit">Add to Cart</button>
  </form>
  <div id="cart-status"></div>
</main>

Trigger Events and Modifiers

HTMX provides a rich trigger system with modifiers for fine-grained control:

<!-- Trigger on multiple events -->
<input type="text" 
       hx-get="/api/suggestions" 
       hx-trigger="input changed delay:500ms, search" 
       hx-target="#suggestions">
 
<!-- Trigger with conditions -->
<form hx-post="/api/submit" hx-trigger="submit[target.checkValidity()]">
  <input type="email" required>
  <button type="submit">Submit</button>
</form>
 
<!-- Trigger on intersection (scroll into view) -->
<div hx-get="/api/lazy-content" hx-trigger="intersect once" hx-swap="innerHTML">
  <div class="placeholder">Loading...</div>
</div>
 
<!-- Polling -->
<div hx-get="/api/status" hx-trigger="every 5s" hx-target="#status-display">
  <div id="status-display">Checking...</div>
</div>
 
<!-- Keyboard shortcuts -->
<button hx-post="/api/save" hx-trigger="keydown[ctrlKey && key==='s'] from:body" hx-swap="none">
  Save
</button>

HTMX trigger patterns

Step-by-Step Implementation

Building a Task Management App

Let's build a complete task management application using HTMX with a Node.js/Express backend:

// server.ts - Express server with HTMX support
import express from 'express';
import { engine } from 'express-handlebars';
 
const app = express();
app.engine('hbs', engine({ extname: '.hbs' }));
app.set('view engine', 'hbs');
app.use(express.urlencoded({ extended: true }));
 
interface Task {
  id: number;
  title: string;
  completed: boolean;
  createdAt: Date;
}
 
let tasks: Task[] = [
  { id: 1, title: 'Learn HTMX', completed: false, createdAt: new Date() },
  { id: 2, title: 'Build an app', completed: false, createdAt: new Date() },
];
 
let nextId = 3;
 
// Full page or fragment helper
const isHtmx = (req: express.Request) => req.headers['hx-request'] === 'true';
 
// GET / - Main page
app.get('/', (req, res) => {
  const view = isHtmx(req) ? 'partials/task-list' : 'index';
  res.render(view, { tasks, layout: !isHtmx(req) });
});
 
// POST /tasks - Create task
app.post('/tasks', (req, res) => {
  const task: Task = { id: nextId++, title: req.body.title, completed: false, createdAt: new Date() };
  tasks.push(task);
  
  if (isHtmx(req)) {
    // Return just the new task HTML fragment
    res.render('partials/task-item', { task }, (err, html) => {
      res.setHeader('HX-Trigger', 'taskCreated');
      res.send(html);
    });
  } else {
    res.redirect('/');
  }
});
 
// PUT /tasks/:id/toggle - Toggle completion
app.put('/tasks/:id/toggle', (req, res) => {
  const task = tasks.find(t => t.id === parseInt(req.params.id));
  if (task) {
    task.completed = !task.completed;
    res.render('partials/task-item', { task });
  } else {
    res.status(404).send('Not found');
  }
});
 
// DELETE /tasks/:id - Delete task
app.delete('/tasks/:id', (req, res) => {
  tasks = tasks.filter(t => t.id !== parseInt(req.params.id));
  res.setHeader('HX-Trigger', 'taskDeleted');
  res.status(200).send('');
});
 
app.listen(3000, () => console.log('Server running on port 3000'));
<!-- views/index.hbs - Main page -->
<!DOCTYPE html>
<html>
<head>
  <title>Task Manager</title>
  <script src="https://unpkg.com/htmx.org@1.9.10"></script>
  <style>
    .task { padding: 10px; border: 1px solid #ddd; margin: 5px 0; display: flex; align-items: center; }
    .task.completed { opacity: 0.6; text-decoration: line-through; }
    .task input[type="checkbox"] { margin-right: 10px; }
    .htmx-indicator { display: none; }
    .htmx-request .htmx-indicator { display: inline; }
  </style>
</head>
<body>
  <h1>Task Manager</h1>
  
  <!-- Task creation form -->
  <form hx-post="/tasks" hx-target="#task-list" hx-swap="beforeend" hx-on::after-request="this.reset()">
    <input type="text" name="title" placeholder="New task..." required>
    <button type="submit">Add Task</button>
    <span class="htmx-indicator">Adding...</span>
  </form>
  
  <!-- Task list with search -->
  <input type="search" 
         name="search" 
         placeholder="Search tasks..." 
         hx-get="/tasks" 
         hx-trigger="input changed delay:300ms, search" 
         hx-target="#task-list"
         hx-include="[name='search']">
  
  <!-- Task list container -->
  <div id="task-list">
    {{> partials/task-list}}
  </div>
  
  <!-- Live counter updated via OOB swap -->
  <div id="task-count">Total: {{tasks.length}} tasks</div>
</body>
</html>
<!-- views/partials/task-item.hbs - Single task fragment -->
<div class="task {{#if completed}}completed{{/if}}" id="task-{{task.id}}">
  <input type="checkbox" 
         {{#if completed}}checked{{/if}}
         hx-put="/tasks/{{task.id}}/toggle" 
         hx-target="closest .task" 
         hx-swap="outerHTML">
  <span>{{task.title}}</span>
  <button hx-delete="/tasks/{{task.id}}" 
          hx-target="closest .task" 
          hx-swap="outerHTML"
          hx-confirm="Delete '{{task.title}}'?">
    πŸ—‘οΈ
  </button>
</div>

Implementing Server-Sent Events (SSE)

HTMX has built-in support for Server-Sent Events, enabling real-time updates:

// SSE endpoint for real-time notifications
app.get('/api/notifications', (req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });
 
  const sendEvent = (data: string, event: string = 'message') => {
    res.write(`event: ${event}\ndata: ${data}\n\n`);
  };
 
  // Send initial status
  sendEvent('<div id="notifications">Connected</div>', 'status');
 
  // Simulate notifications
  let count = 0;
  const interval = setInterval(() => {
    count++;
    const html = `<div class="notification" hx-swap-oob="beforeend:#notification-list">
      Notification #${count}: Something happened!
    </div>`;
    sendEvent(html, 'notification');
  }, 10000);
 
  req.on('close', () => {
    clearInterval(interval);
    res.end();
  });
});
<!-- SSE integration in HTML -->
<div hx-ext="sse" sse-connect="/api/notifications" sse-swap="notification">
  <h3>Notifications</h3>
  <div id="notification-list">
    <!-- Notifications will appear here -->
  </div>
</div>

Real-World Use Cases and Case Studies

Use Case 1: Dynamic Form Builder

HTMX excels at dynamic forms where fields appear/disappear based on user selections:

<!-- Dynamic form with conditional fields -->
<form hx-post="/api/applications" hx-target="#result">
  <select name="type" hx-get="/api/form-fields" hx-target="#dynamic-fields" hx-include="[name='type']">
    <option value="">Select type...</option>
    <option value="personal">Personal</option>
    <option value="business">Business</option>
  </select>
  
  <div id="dynamic-fields">
    <!-- Dynamic fields loaded here based on selection -->
  </div>
  
  <button type="submit">Submit</button>
</form>
// Server returns appropriate form fields based on type
app.get('/api/form-fields', (req, res) => {
  const type = req.query.type;
  
  const fields: Record<string, string> = {
    personal: `
      <label>Full Name <input type="text" name="fullName" required></label>
      <label>Date of Birth <input type="date" name="dob" required></label>
      <label>Phone <input type="tel" name="phone"></label>
    `,
    business: `
      <label>Company Name <input type="text" name="companyName" required></label>
      <label>Tax ID <input type="text" name="taxId" required></label>
      <label>Industry <select name="industry">
        <option value="tech">Technology</option>
        <option value="finance">Finance</option>
        <option value="health">Healthcare</option>
      </select></label>
      <label>Employee Count <input type="number" name="employees" min="1"></label>
    `
  };
  
  res.send(fields[type as string] || '<p>Please select a type</p>');
});

Use Case 2: Inline Editing

<!-- Click to edit pattern -->
<div hx-target="this" hx-swap="outerHTML">
  <div class="display-mode">
    <span>{{task.title}}</span>
    <button hx-get="/api/tasks/{{task.id}}/edit">Edit</button>
  </div>
</div>
 
<!-- Server returns this when "Edit" is clicked -->
<div hx-target="this" hx-swap="outerHTML">
  <form hx-put="/api/tasks/{{task.id}}" hx-target="this" hx-swap="outerHTML">
    <input type="text" name="title" value="{{task.title}}">
    <button type="submit">Save</button>
    <button hx-get="/api/tasks/{{task.id}}" type="button">Cancel</button>
  </form>
</div>

Use Case 3: Real-Time Search with Caching

// Express endpoint with search caching
const searchCache = new Map<string, { results: any[]; timestamp: number }>();
const CACHE_TTL = 60000; // 1 minute
 
app.get('/api/search', (req, res) => {
  const query = (req.query.q as string || '').toLowerCase().trim();
  
  if (query.length < 2) {
    return res.send('<div id="search-results">Type at least 2 characters</div>');
  }
  
  // Check cache
  const cached = searchCache.get(query);
  if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
    return res.send(renderResults(cached.results));
  }
  
  // Perform search
  const results = items.filter(item => 
    item.name.toLowerCase().includes(query) || 
    item.description.toLowerCase().includes(query)
  );
  
  searchCache.set(query, { results, timestamp: Date.now() });
  res.send(renderResults(results));
});
 
function renderResults(results: any[]): string {
  if (results.length === 0) return '<div id="search-results">No results found</div>';
  
  const items = results.map(r => `
    <div class="search-result">
      <h4>${r.name}</h4>
      <p>${r.description}</p>
    </div>
  `).join('');
  
  return `<div id="search-results">${items}</div>`;
}

Best Practices for Production

  1. Always return proper HTTP status codes β€” Use 4xx for client errors and 5xx for server errors. HTMX can handle these with hx-on::response-error for user feedback.

  2. Use the HX-Trigger header for cross-element communication β€” When an action affects multiple parts of the page, use HX-Trigger to fire custom events that other elements can listen for.

  3. Implement progressive enhancement β€” Design your forms and links to work without HTMX first (standard form submissions), then add HTMX attributes for enhanced behavior.

  4. Cache fragment responses β€” Since HTMX requests return HTML fragments, implement server-side caching with ETags or Last-Modified headers to reduce server load.

  5. Use hx-boost for site-wide enhancement β€” The hx-boost attribute converts all links and forms in a container to HTMX-powered AJAX requests, giving an SPA-like feel to traditional multi-page apps.

  6. Validate on both client and server β€” Use HTML5 validation attributes for immediate feedback and server-side validation for security. HTMX respects target.checkValidity() in triggers.

  7. Implement proper error states β€” Use hx-on::after-request or CSS classes (htmx-settling, htmx-swapping) to show loading states, errors, and success messages.

  8. Use hx-swap-oob for multi-element updates β€” When a single request needs to update multiple parts of the page, use out-of-band swaps to target additional elements.

Common Pitfalls and Solutions

PitfallImpactSolution
Server returns full page instead of fragmentEntire page replaced unexpectedlyCheck HX-Request header and return appropriate response
Missing CSRF tokensForms fail with 403 errorsInclude CSRF token in meta tag and use hx-headers or hidden inputs
No loading indicatorsPoor perceived performanceUse hx-indicator and CSS for loading states
Memory leaks with SSEServer resource exhaustionImplement proper connection cleanup on disconnect
XSSI attacksSecurity vulnerabilityPrefix JSON responses with )]} or use proper CORS
Accessibility issuesExcluded usersEnsure HTMX interactions work with keyboard and screen readers

Performance Optimization

HTMX offers several built-in performance optimizations:

<!-- Request debouncing for search -->
<input type="search" hx-get="/api/search" hx-trigger="input changed delay:500ms">
 
<!-- Conditional requests (only if value changed) -->
<input hx-get="/api/filter" hx-trigger="change" hx-target="#results">
 
<!-- Lazy loading below-fold content -->
<div hx-get="/api/heavy-content" hx-trigger="intersect once" hx-swap="innerHTML">
  <div class="skeleton-loader">Loading...</div>
</div>
 
<!-- Prefetching on hover -->
<a href="/products/123" hx-get="/products/123" hx-trigger="mouseenter once" hx-target="#prefetch" hx-swap="none">
  View Product
</a>
<div id="prefetch" style="display:none"></div>
// Server-side optimization: fragment caching
import NodeCache from 'node-cache';
const cache = new NodeCache({ stdTTL: 60 });
 
app.get('/api/dashboard', async (req, res) => {
  const cacheKey = `dashboard:${req.user.id}`;
  const cached = cache.get<string>(cacheKey);
  
  if (cached) {
    res.setHeader('HX-Cache', 'hit');
    return res.send(cached);
  }
  
  const data = await fetchDashboardData(req.user.id);
  const html = renderDashboardFragment(data);
  cache.set(cacheKey, html);
  res.setHeader('HX-Cache', 'miss');
  res.send(html);
});

Comparison with Alternatives

FeatureHTMXReactVueAlpine.js
Bundle Size14KB42KB33KB15KB
Learning CurveVery LowModerateModerateLow
Build StepNoneRequiredOptionalOptional
State ManagementServerClient (Redux)Client (Vuex)Client (local)
Server RenderingNativeNext.jsNuxtNative
Complex UIsChallengingExcellentExcellentGood
Real-time SupportBuilt-in (SSE/WS)Manual setupManual setupManual setup
Best ForServer-rendered appsSPAs, Complex UIsSPAs, ProgressiveSmall enhancements

Advanced Patterns and Techniques

// HTMX with WebSocket support
// Client-side:
// <div hx-ws="connect:/api/ws" hx-trigger="message">
// Server-side WebSocket handler:
import WebSocket, { WebSocketServer } from 'ws';
 
const wss = new WebSocketServer({ port: 8080 });
 
wss.on('connection', (ws) => {
  ws.on('message', (data) => {
    const message = JSON.parse(data.toString());
    
    if (message.HEADERS?.['HX-Trigger'] === 'chat-form') {
      // Broadcast HTML fragment to all clients
      const html = `<div class="message" hx-swap-oob="beforeend:#messages">
        <strong>${message.username}:</strong> ${message.text}
      </div>`;
      
      wss.clients.forEach(client => {
        if (client.readyState === WebSocket.OPEN) {
          client.send(html);
        }
      });
    }
  });
});
<!-- HTMX with Web Components for complex islands -->
<template id="data-table-template">
  <style>
    table { width: 100%; border-collapse: collapse; }
    th, td { padding: 8px; border: 1px solid #ddd; text-align: left; }
    .sortable { cursor: pointer; }
    .sortable:hover { background: #f0f0f0; }
  </style>
  <table>
    <thead>
      <tr><th class="sortable" hx-get="/api/data?sort=name" hx-target="closest table" hx-swap="outerHTML">Name</th></tr>
    </thead>
    <tbody>
      <slot></slot>
    </tbody>
  </table>
</template>
 
<script>
class DataTable extends HTMLElement {
  constructor() {
    super();
    const template = document.getElementById('data-table-template');
    this.attachShadow({ mode: 'open' }).appendChild(template.content.cloneNode(true));
  }
}
customElements.define('data-table', DataTable);
</script>

Testing Strategies

// Testing HTMX applications with Playwright
import { test, expect } from '@playwright/test';
 
test.describe('Task Manager with HTMX', () => {
  test('can add a task', async ({ page }) => {
    await page.goto('/');
    
    await page.fill('input[name="title"]', 'New Task');
    await page.click('button[type="submit"]');
    
    // HTMX swaps the fragment
    await expect(page.locator('.task')).toContainText('New Task');
  });
 
  test('can toggle task completion', async ({ page }) => {
    await page.goto('/');
    
    await page.click('#task-1 input[type="checkbox"]');
    
    // Task should get 'completed' class
    await expect(page.locator('#task-1')).toHaveClass(/completed/);
  });
 
  test('can delete a task', async ({ page }) => {
    await page.goto('/');
    
    page.on('dialog', dialog => dialog.accept());
    await page.click('#task-1 button[hx-delete]');
    
    await expect(page.locator('#task-1')).not.toBeVisible();
  });
 
  test('search filters results', async ({ page }) => {
    await page.goto('/');
    
    await page.fill('input[name="search"]', 'HTMX');
    
    // Wait for debounce
    await page.waitForTimeout(500);
    
    const tasks = await page.locator('.task').count();
    expect(tasks).toBeLessThanOrEqual(2); // Only matching tasks
  });
});

Future Outlook

HTMX continues to evolve with HTMX 2.0 on the roadmap, bringing improved extension support, better accessibility defaults, and smaller bundle size. The hyperscript companion language provides inline scripting capabilities without full JavaScript.

The broader trend toward server-driven UI is accelerating, with frameworks like Hotwire (Turbo + Stimulus), LiveView (Elixir/Phoenix), and LiveWire (Laravel) following similar philosophies. HTMX's influence extends beyond its own libraryβ€”it's changing how developers think about web application architecture.

Conclusion

HTMX offers a powerful alternative to JavaScript-heavy frameworks by leveraging HTML's native capabilities. The key takeaways are:

  1. HTML as the primary application format β€” HTMX extends HTML with attributes for AJAX, SSE, and WebSocket support
  2. Server owns the state β€” No client-side state synchronization; the server is always the source of truth
  3. Progressive enhancement β€” Build functional pages first, then add HTMX for enhanced interactivity
  4. Simplicity is a feature β€” No build step, no complex state management, no bundler configuration
  5. Excellent for server-rendered apps β€” Django, Rails, Laravel, and Spring Boot all integrate naturally

Start by adding hx-boost to your existing multi-page application for instant SPA-like navigation, then progressively replace form submissions and interactions with HTMX-powered alternatives. You'll be surprised how much interactivity you can add without writing a single line of JavaScript.