Skip to main content
โšก Calmops

HTMX: Modern Hypermedia for Simple Web Applications

HTMX is a library that enables dynamic interactivity in web applications using a hypermedia-first approach. Instead of writing complex JavaScript, HTMX lets you build modern user interfaces using HTML attributes. This comprehensive guide covers everything you need to know about HTMX.

What is HTMX?

HTMX is a JavaScript library (under 14KB gzipped) that extends HTML with powerful attributes for handling AJAX requests, WebSocket connections, and Server-Sent Events directly from HTML elements.

<!-- Traditional approach: Write JavaScript -->
<button id="load-more">Load More</button>
<script>
document.getElementById('load-more').addEventListener('click', async () => {
  const response = await fetch('/api/posts');
  const posts = await response.json();
  // Render posts to DOM...
});
</script>

<!-- HTMX approach: Just HTML -->
<button hx-get="/api/posts" hx-target="#posts" hx-swap="beforeend">
  Load More
</button>
<div id="posts"></div>

Core Philosophy

HTMX embraces the hypermedia-driven architecture:

  • HTML as the API - Server returns HTML fragments, not JSON
  • Progressive enhancement - Works without JavaScript (with graceful degradation)
  • Simplicity - No build steps, no virtual DOM, no complex state management
  • Small footprint - Single library replaces React, Vue, or Angular for many use cases
<!-- HTMX CDN - Add to any HTML page -->
<script src="https://unpkg.com/[email protected]"></script>

HTMX Attributes

HTMX extends HTML with attributes that begin with hx-. Each attribute controls a specific aspect of the AJAX behavior.

Request Attributes

<!-- hx-get: Make GET request -->
<a hx-get="/users" hx-target="#user-list">Load Users</a>

<!-- hx-post: Make POST request -->
<form hx-post="/contact" hx-target="#response">
  <input type="email" name="email" required>
  <button type="submit">Subscribe</button>
</form>

<!-- hx-put: Make PUT request -->
<button hx-put="/users/123" hx-confirm="Are you sure?">Update User</button>

<!-- hx-delete: Make DELETE request -->
<button hx-delete="/posts/456" hx-swap="outerHTML">Delete Post</button>

<!-- hx-patch: Make PATCH request -->
<button hx-patch="/settings" hx-include="[name='theme']">Save Settings</button>

Target and Swap

<!-- hx-target: Where to insert the response -->
<div hx-get="/notifications" hx-target="#notification-area">
  Click to load notifications
</div>

<!-- Special targets -->
<button hx-get="/details" hx-target="this">Replace this button</button>
<button hx-get="/next" hx-target="next .card">Target next sibling card</button>

<!-- hx-swap: How to insert the response -->
<div hx-get="/comments" hx-swap="innerHTML">Load comments</div>

<!-- Swap options -->
<!-- innerHTML - Insert inside target (default) -->
<!-- outerHTML - Replace the target entirely -->
<!-- beforebegin - Insert before target -->
<!-- afterbegin - Insert as first child -->
<!-- beforeend - Insert as last child -->
<!-- afterend - Insert after target -->
<!-- delete - Remove the target -->
<!-- none - Don't swap (useful for events) -->

Trigger Attributes

<!-- hx-trigger: What event triggers the request -->
<input type="text" hx-get="/search" hx-trigger="keyup changed delay:300ms">

<!-- Event modifiers -->
<button hx-post="/click" hx-trigger="click">Click me</button>
<form hx-post="/submit" hx-trigger="submit">...</form>

<!-- Multiple triggers -->
<div hx-get="/updates" hx-trigger="click, every 30s">

<!-- Condition-based triggers -->
<input type="text" hx-get="/validate" hx-trigger="keyup changed">

<!-- Initial load -->
<div hx-get="/initial-data" hx-trigger="load">

Extended Headers

<!-- hx-headers: Add custom headers -->
<button hx-get="/api" hx-headers='{"X-Custom-Token": "abc123"'>
  Fetch with headers
</button>

<!-- hx-request: Configure request -->
<button hx-get="/slow" hx-request='{"timeout": 5000}'>
  5 second timeout
</button>

<!-- hx-vals: Add JSON values -->
<button hx-post="/action" hx-vals='{"source": "button"}'>
  With extra data
</button>

Working with Forms

HTMX handles form submissions elegantly with HTML-centric behavior.

Basic Form Submission

<!-- Simple form with HTMX -->
<form hx-post="/contact" hx-target="#form-response" hx-swap="innerHTML">
  <div>
    <label for="name">Name:</label>
    <input type="text" id="name" name="name" required>
  </div>
  
  <div>
    <label for="email">Email:</label>
    <input type="email" id="email" name="email" required>
  </div>
  
  <div>
    <label for="message">Message:</label>
    <textarea id="message" name="message" rows="4"></textarea>
  </div>
  
  <button type="submit">Send Message</button>
</form>

<div id="form-response"></div>

File Upload

<!-- File upload with progress -->
<form hx-post="/upload" hx-target="#upload-result" enctype="multipart/form-data">
  <input type="file" name="document" accept=".pdf,.doc,.docx">
  <button type="submit">Upload</button>
</form>

<div id="upload-result"></div>

<!-- Progress indicator -->
<div hx-get="/upload/progress" hx-trigger="every 200ms" hx-target="this">
  <progress id="upload-progress" value="0" max="100"></progress>
</div>

Form Validation

<!-- Inline validation -->
<form hx-post="/validate" hx-target="this" hx-swap="outerHTML">
  <input type="text" 
         name="username" 
         hx-post="/validate/username" 
         hx-trigger="change" 
         hx-target="next .error"
         placeholder="Choose username">
  <span class="error"></span>
  
  <input type="email" name="email" placeholder="Your email">
  <span class="error"></span>
  
  <button type="submit">Register</button>
</form>

Dynamic Content Loading

Infinite Scroll

<!-- Infinite scroll implementation -->
<div id="posts-container">
  <!-- Initial posts loaded server-side -->
  <article>Post 1 content...</article>
  <article>Post 2 content...</article>
</div>

<!-- Load more trigger -->
<div hx-get="/posts?page=2" 
     hx-trigger="revealed" 
     hx-swap="beforeend" 
     hx-target="#posts-container">
  <img src="/images/loading.gif" alt="Loading more...">
</div>

Tab Interface

<!-- Simple tabs with HTMX -->
<div class="tabs">
  <button hx-get="/tab/dashboard" 
          hx-target="#tab-content" 
          hx-swap="innerHTML"
          class="active">
    Dashboard
  </button>
  <button hx-get="/tab/reports" 
          hx-target="#tab-content" 
          hx-swap="innerHTML">
    Reports
  </button>
  <button hx-get="/tab/settings" 
          hx-target="#tab-content" 
          hx-swap="innerHTML">
    Settings
  </button>
</div>

<div id="tab-content">
  <!-- Tab content loads here -->
  <p>Select a tab to load content.</p>
</div>

Search with Debounce

<!-- Search with debounced requests -->
<div class="search-container">
  <input type="search" 
         name="q" 
         placeholder="Search products..." 
         hx-get="/search" 
         hx-trigger="keyup changed delay:300ms, search" 
         hx-target="#search-results"
         hx-indicator="#searching">
  
  <img id="searching" 
       class="htmx-indicator" 
       src="/images/spinner.gif" 
       alt="Searching...">
</div>

<div id="search-results">
  <!-- Search results appear here -->
</div>

<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request.htmx-indicator { display: inline; }
</style>
<!-- Click to open modal -->
<button hx-get="/modal/content" 
        hx-target="body" 
        hx-swap="beforeend">
  Open Modal
</button>

<!-- Modal template (server returns this) -->
<!-- 
<div id="modal" class="modal">
  <div class="modal-content">
    <span hx-get="/close/modal" hx-target="closest #modal" hx-swap="outerHTML" class="close">&times;</span>
    <h2>Modal Title</h2>
    <p>Modal content...</p>
  </div>
</div>
-->

Server-Side Integration

HTMX works with any server-side language or framework. The server returns HTML fragments, not JSON.

Python (Flask) Example

from flask import Flask, render_template_string, request

app = Flask(__name__)

# Simple page with HTMX
@app.route('/')
def index():
    return render_template_string('''
<!DOCTYPE html>
<html>
<head>
    <title>HTMX Demo</title>
    <script src="https://unpkg.com/[email protected]"></script>
    <style>
        .htmx-indicator { display: none; }
        .htmx-request .htmx-indicator { display: inline; }
    </style>
</head>
<body>
    <h1>HTMX Demo</h1>
    
    <button hx-get="/time" hx-target="#time" hx-swap="innerHTML">
        Get Current Time
    </button>
    <span id="time">-</span>
    
    <img class="htmx-indicator" src="/loading.gif">
    
    <form hx-post="/greet" hx-target="#greeting">
        <input type="text" name="name" placeholder="Your name">
        <button type="submit">Greet</button>
    </form>
    <div id="greeting"></div>
</body>
</html>
    ''')

@app.route('/time')
def get_time():
    from datetime import datetime
    return f'<span>{datetime.now().strftime("%H:%M:%S")}</span>'

@app.route('/greet', methods=['POST'])
def greet():
    name = request.form.get('name', 'World')
    return f'<div class="greeting">Hello, {name}!</div>'

if __name__ == '__main__':
    app.run(debug=True)

Node.js (Express) Example

const express = require('express');
const app = express();

app.use(express.urlencoded({ extended: true }));

// Main page
app.get('/', (req, res) => {
  res.send(`
<!DOCTYPE html>
<html>
<head>
  <title>HTMX Express Demo</title>
  <script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
  <h1>HTMX + Express Demo</h1>
  
  <button hx-get="/api/random" hx-target="#random-number">
    Generate Random Number
  </button>
  <span id="random-number">-</span>
  
  <form hx-post="/api/reverse" hx-target="#reversed">
    <input type="text" name="text" placeholder="Type something...">
    <button type="submit">Reverse</button>
  </form>
  <div id="reversed"></div>
</body>
</html>
  `);
});

// HTMX endpoints return HTML fragments
app.get('/api/random', (req, res) => {
  const num = Math.floor(Math.random() * 100);
  res.send(`<span>${num}</span>`);
});

app.post('/api/reverse', (req, res) => {
  const text = req.body.text || '';
  const reversed = text.split('').reverse().join('');
  res.send(`<div>Reversed: <strong>${reversed}</strong></div>`);
});

app.listen(3000, () => console.log('Server running on port 3000'));

Go Example

package main

import (
	"fmt"
	"net/http"
	"time"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html")
		w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
    <title>HTMX Go Demo</title>
    <script src="https://unpkg.com/[email protected]"></script>
</head>
<body>
    <h1>HTMX + Go Demo</h1>
    
    <button hx-get="/time" hx-target="#time-display">
        Get Server Time
    </button>
    <div id="time-display">-</div>
    
    <form hx-post="/calc" hx-target="#calc-result">
        <input type="number" name="a" placeholder="A" style="width: 60px">
        <span>+</span>
        <input type="number" name="b" placeholder="B" style="width: 60px">
        <button type="submit">Add</button>
    </form>
    <div id="calc-result"></div>
</body>
</html>
		`))
	})

	http.HandleFunc("/time", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/html")
		fmt.Fprintf(w, "<span>%s</span>", time.Now().Format("15:04:05"))
	})

	http.HandleFunc("/calc", func(w http.ResponseWriter, r *http.Request) {
		r.ParseForm()
		var a, b int
		fmt.Sscanf(r.FormValue("a"), "%d", &a)
		fmt.Sscanf(r.FormValue("b"), "%d", &b)
		w.Header().Set("Content-Type", "text/html")
		fmt.Fprintf(w, "<div>Result: <strong>%d</strong></div>", a+b)
	})

	http.ListenAndServe(":8080", nil)
}

Ruby on Rails Example

# config/routes.rb
Rails.application.routes.draw do
  root to: 'demo#index'
  get 'demo/time', to: 'demo#time'
  post 'demo/search', to: 'demo#search'
end

# app/controllers/demo_controller.rb
class DemoController < ApplicationController
  def index
    # Renders the main page with HTMX
  end
  
  def time
    render plain: "<span>#{Time.current.strftime('%H:%M:%S')}</span>"
  end
  
  def search
    query = params[:q]
    results = Article.where("title LIKE ?", "%#{query}%").limit(10)
    render partial: 'results', locals: { results: results }
  end
end

# app/views/demo/index.html.erb
<h1>HTMX Rails Demo</h1>

<button hx-get="/demo/time" hx-target="#time">
  Get Time
</button>
<span id="time">-</span>

<%= form_with url: "/demo/search", 
              method: :post, 
              data: { hx_post: "/demo/search", hx_target: "#results" } do |f| %>
  <%= f.text_field :q, placeholder: "Search articles..." %>
  <%= f.submit "Search" %>
<% end  %>

<div id="results"></div>

Advanced Patterns

Error Handling

<!-- hx-on: Handle errors with inline JavaScript -->
<button hx-get="/error-prone" 
        hx-target="#result"
        hx-swap="innerHTML"
        hx-on::after-request="if (this.htmx.isError(this)) { alert('Request failed!'); }">
  Test Error
</button>

<!-- hx-error: Show error message -->
<form hx-post="/api" hx-target="#response">
  <input type="text" name="data">
  <button type="submit">Submit</button>
</form>
<div id="response"></div>

<!-- Server returns error in header for HTMX to display -->

Loading States

<!-- Using hx-indicator class -->
<button hx-get="/slow-api" hx-target="#result">
  <span class="htmx-indicator">Loading...</span>
  <span class="htmx-inverse-indicator">Click Me</span>
</button>

<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
.htmx-request .htmx-inverse-indicator { display: none; }
</style>

<!-- Using CSS transitions -->
<div hx-get="/content" hx-trigger="load" class="fade-in">
  Loading...
</div>

<style>
.fade-in { opacity: 0; transition: opacity 0.3s; }
.htmx-request .fade-in { opacity: 0.5; }
.htmx-settle .fade-in { opacity: 1; }
</style>

Optimistic UI

<!-- Optimistic toggle -->
<button hx-post="/toggle-like/123" 
        hx-swap="outerHTML"
        class="like-button">
  <span class="icon">โ™ก</span> Like
</button>

<!-- Server returns updated button state -->
<!-- Before: <button ...>โ™ก Like</button> -->
<!-- After:  <button ...>โ™ฅ Liked</button> -->

<!-- Using hx-on for immediate feedback -->
<button hx-delete="/item/123" 
        hx-swap="outerHTML"
        hx-on::before-request="this.innerHTML='Deleting...'">
  Delete Item
</button>

WebSocket Integration

<!-- Real-time updates with WebSocket -->
<div hx-ws="connect:/ws">
  <div id="messages"></div>
  
  <form hx-ws="send">
    <input type="text" name="message" placeholder="Type a message...">
    <button type="submit">Send</button>
  </form>
</div>

<!-- Server sends HTML fragments via WebSocket -->
<!-- 
<div hx-ws="swap:messages">New message content</div>
-->

Server-Sent Events

<!-- Real-time updates with SSE -->
<div hx-sse="connect:/sse/updates">
  <div hx-sse="swap:message" id="live-updates">
    Waiting for updates...
  </div>
</div>

<!-- Server sends: -->
<!-- event: message -->
<!-- data: <div>New data received!</div> -->

HTMX vs Traditional SPAs

Aspect HTMX React/Vue/Angular
Bundle Size ~14KB 30-100+ KB
Build Step Not required Required (webpack/vite)
State Management Server state Complex client state
Learning Curve Low High
SEO Excellent Requires SSR
Initial Load Fast Can be slow
Developer Experience HTML-centric JSX/Template-centric
Mobile Performance Good Good

When to Use HTMX

flowchart TD
    A[Building Web App?] --> B{Team Size}
    B --> C[Small Team]
    B --> D[Large Team]
    
    C --> E{Complexity}
    E --> F[Simple CRUD]
    F --> G[Use HTMX โœ“]
    E --> H[Highly Interactive]
    H --> I[Consider React/Vue]
    
    D --> J{Interactivity}
    J --> K[Mostly Static]
    K --> L[Use HTMX โœ“]
    J --> M[Complex State]
    M --> N[Use React/Vue โœ“]
    
    O[PExisting Backend] --> P[Use HTMX]
    O --> Q[New Full- --> R{Stack]Preference}
    R --> S[Simplicity] --> T[Use HTMX]
    R --> U[Modern Stack] --> V[Use Next.js/Nuxt]

HTMX Extensions

HTMX has an extension system for additional functionality.

Extensions You Should Know

<!-- Class Tools: Better class manipulation -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/class-tools.js"></script>

<!-- Using class-tools for morphing -->
<button hx-get="/update" 
        hx-target="#content" 
        _="on click toggle .loading on closest .container">
  Update
</button>

<!-- JSON Schema: Submit as JSON -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/json-enc.js"></script>

<button hx-post="/api" 
        hx-ext="json-enc" 
        hx-vals='{"key":"value"}'>
  Send JSON
</button>

<!-- Server-Sent Events Extension -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/sse.js"></script>

<!-- WebSocket Extension -->
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/dist/ext/ws.js"></script>

Best Practices

Do: Return HTML Fragments

# Good: Server returns HTML
@app.route('/users')
def users():
    users = User.query.all()
    return render_template('users/list.html', users=users)

# Bad: Server returns JSON (defeats HTMX purpose)
@app.route('/users')
def users():
    return jsonify([user.to_dict() for user in User.query.all()])

Do: Use Semantic HTML

<!-- Good: Semantic and accessible -->
<button hx-delete="/item/1" hx-confirm="Delete this item?">
  Delete
</button>

<!-- Bad: Using div as button -->
<div hx-delete="/item/1">Delete</div>

Don’t Over-Nest

<!-- Avoid deep nesting -->
<div hx-get="/level1">
  <div hx-get="/level2">
    <div hx-get="/level3">
      <!-- Hard to maintain -->
    </div>
  </div>
</div>

<!-- Better: Flat structure -->
<div id="app">
  <div hx-get="/dashboard" hx-target="#app" hx-swap="innerHTML">
</div>

Handle Loading States

<!-- Always show feedback during requests -->
<button hx-get="/data" hx-target="#result" class="btn">
  <span class="btn-text">Load Data</span>
  <img class="htmx-indicator" src="/spinner.gif">
</button>

<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: block; }
.htmx-request .btn-text { opacity: 0.5; }
</style>

Integration with Common Frameworks

Django

# views.py
from django.shortcuts import render
from django.http import JsonResponse

def index(request):
    return render(request, 'index.html')

def htmx_endpoint(request):
    # Check if it's an HTMX request
    if request.headers.get('HX-Request'):
        # Return HTML fragment
        return render(request, 'partials/content.html', {'item': item})
    # Return full page
    return render(request, 'full-page.html', {'item': item})
<!-- templates/index.html -->
{% load htmx %}
<script src="https://unpkg.com/[email protected]"></script>

<button hx-get="{% url 'htmx_endpoint' %}" hx-target="#result">
  Load
</button>
<div id="result"></div>

Spring Boot

@GetMapping(value = "/users", produces = "text/html")
public String getUsers(Model model, @RequestHeader(value = "HX-Request", required = false) String hxRequest) {
    model.addAttribute("users", userService.findAll());
    
    // Return partial for HTMX, full for regular requests
    if (hxRequest != null) {
        return "fragments/users :: list";
    }
    return "pages/users";
}

Common Pitfalls

1. Returning JSON Instead of HTML

<!-- Server returns JSON -->
<!-- Problem: HTMX expects HTML -->
<!-- Fix: Return HTML fragment -->

<!-- Bad -->
app.get('/data', (req, res) => {
  res.json({ message: 'Hello' });
});

<!-- Good -->
app.get('/data', (req, res) => {
  res.send('<span>Hello</span>');
});

2. Missing CSRF Token

<!-- Add CSRF token to all requests -->
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
  event.detail.headers['X-CSRFToken'] = '{{ csrf_token }}';
});
</script>

3. Not Handling Errors

<!-- Add error handling -->
<div hx-get="/api" 
     hx-target="#result"
     hx-on::after-request="if (this.htmx.isError(this)) { 
       document.getElementById('result').innerHTML = '<span class=error>Failed!</span>'; 
     }">
</div>

External Resources

Conclusion

HTMX brings simplicity back to web development by embracing HTML as the primary interface definition language. For applications that don’t require the complexity of modern JavaScript frameworks, HTMX offers a compelling alternative that is faster to build, easier to maintain, and more accessible by default.

The hypermedia-driven approach works exceptionally well for:

  • Content-focused websites
  • Simple CRUD applications
  • Dashboards with moderate interactivity
  • Prototypes and MVPs
  • Teams with strong backend skills

For highly interactive applications with complex client-side state, consider combining HTMX with a lighter-weight approach or using a traditional SPA framework.

Comments