The modern web development landscape is dominated by client-side JavaScript frameworks. React, Vue, and Svelte have become the default choice for building interactive web applications, bringing with them complex build toolchains, state management libraries, and megabytes of JavaScript shipped to the browser. HTMX offers a radically different approach. By extending HTML with a handful of attributes, it lets you build surprisingly interactive applications while keeping your logic on the server and your pages lightweight.
Hypermedia-driven architecture is not a new concept. It is the original architecture of the web, where the server sends complete representations that include both data and the controls for interacting with that data. HTMX revives this pattern by letting any HTML element make HTTP requests and update any part of the page with the HTML response. This guide covers the core concepts, practical patterns, and architectural decisions you need to build production applications with HTMX.
Core HTMX Attributes and Concepts
HTMX works by adding attributes to HTML elements that describe what HTTP request to make, what event triggers it, and where to put the response. The four foundational attributes are hx-get, hx-post, hx-target, and hx-swap. Together, they cover the vast majority of interactive patterns you encounter in web applications.
<!-- Load content on click -->
<button hx-get="/api/notifications"
hx-target="#notification-panel"
hx-swap="innerHTML">
Check Notifications
</button>
<div id="notification-panel">
<!-- Server HTML will be placed here -->
</div>
<!-- Submit a form without full page reload -->
<form hx-post="/contacts"
hx-target="#contact-list"
hx-swap="afterbegin">
<input name="name" placeholder="Name" required>
<input name="email" placeholder="Email" required>
<button type="submit">Add Contact</button>
</form>
<!-- Delete with confirmation -->
<button hx-delete="/contacts/42"
hx-confirm="Remove this contact?"
hx-target="closest tr"
hx-swap="outerHTML swap:500ms">
Delete
</button>
The hx-swap attribute controls how the response HTML is inserted. Options include innerHTML (replace children), outerHTML (replace the target element itself), afterbegin (prepend), beforeend (append), beforebegin (insert before), and afterend (insert after). The swap modifier adds a delay for CSS transitions, allowing smooth animations without custom JavaScript.
The hx-trigger attribute specifies which event initiates the request. By default, buttons and inputs use click, and forms use submit. You can specify any DOM event, add modifiers for debouncing, throttling, or conditional triggers.
<!-- Search with debounce -->
<input type="search"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
placeholder="Search articles...">
<!-- Infinite scroll -->
<div hx-get="/articles?page=2"
hx-trigger="revealed"
hx-swap="afterend">
Loading more...
</div>
<!-- Poll for updates every 5 seconds -->
<div hx-get="/dashboard/stats"
hx-trigger="every 5s"
hx-swap="innerHTML">
<!-- Stats update automatically -->
</div>
Server-Side Patterns for HTMX
The server side of an HTMX application differs from a JSON API in one critical way: endpoints return HTML fragments, not JSON objects. This means your server renders templates and returns partial views. Most web frameworks support this naturally through their template engines.
Consider a contact management application built with Python and Flask. The server handles full-page requests for initial loads and partial requests for HTMX interactions using the same templates.
# Python / Flask example
from flask import Flask, render_template, request
app = Flask(__name__)
@app.route("/contacts")
def contact_list():
search = request.args.get("q", "")
contacts = Contact.search(search) if search else Contact.all()
# Check if this is an HTMX request
if request.headers.get("HX-Request"):
# Return only the contact list fragment
return render_template("partials/contact_rows.html",
contacts=contacts)
# Full page for direct navigation
return render_template("contacts.html", contacts=contacts)
@app.route("/contacts", methods=["POST"])
def create_contact():
name = request.form["name"]
email = request.form["email"]
errors = validate_contact(name, email)
if errors:
return render_template("partials/contact_form.html",
errors=errors,
name=name,
email=email), 422
contact = Contact.create(name=name, email=email)
return render_template("partials/contact_row.html",
contact=contact)
@app.route("/contacts/<int:id>", methods=["DELETE"])
def delete_contact(id):
Contact.delete(id)
return "" # Empty response removes the target element
The key pattern is the HX-Request header check. HTMX sends this header with every request, letting you distinguish between a full-page navigation (which needs the complete layout) and a partial update (which needs only the relevant fragment). This dual-rendering approach means your application works with and without JavaScript, achieving true progressive enhancement.
Template partials become the building blocks of your UI. Each partial represents a component that can be rendered independently and swapped into the page.
<!-- partials/contact_row.html -->
<tr id="contact-{{ contact.id }}">
<td>{{ contact.name }}</td>
<td>{{ contact.email }}</td>
<td>
<button hx-get="/contacts/{{ contact.id }}/edit"
hx-target="#contact-{{ contact.id }}"
hx-swap="outerHTML">
Edit
</button>
<button hx-delete="/contacts/{{ contact.id }}"
hx-confirm="Delete {{ contact.name }}?"
hx-target="#contact-{{ contact.id }}"
hx-swap="outerHTML swap:500ms">
Delete
</button>
</td>
</tr>
<!-- partials/contact_edit.html -->
<tr id="contact-{{ contact.id }}">
<td>
<form hx-put="/contacts/{{ contact.id }}"
hx-target="#contact-{{ contact.id }}"
hx-swap="outerHTML">
<input name="name" value="{{ contact.name }}">
</td>
<td>
<input name="email" value="{{ contact.email }}">
</td>
<td>
<button type="submit">Save</button>
<button hx-get="/contacts/{{ contact.id }}"
hx-target="#contact-{{ contact.id }}"
hx-swap="outerHTML">
Cancel
</button>
</form>
</td>
</tr>
Advanced Interaction Patterns
HTMX supports patterns that go well beyond simple CRUD operations. Out-of-band swaps, request indicators, and response headers give you fine-grained control over how the page updates in response to server actions.
Out-of-band swaps let a single server response update multiple parts of the page. This is essential for operations that affect several areas simultaneously, like a form submission that updates both a list and a counter.
<!-- Server response with out-of-band swap -->
<!-- Primary response: goes to hx-target -->
<tr id="contact-99">
<td>New Contact</td>
<td>[email protected]</td>
</tr>
<!-- Out-of-band: updates the contact count -->
<span id="contact-count" hx-swap-oob="true">
43 contacts
</span>
<!-- Out-of-band: clear the form -->
<form id="add-contact-form" hx-swap-oob="true"
hx-post="/contacts"
hx-target="#contact-list tbody"
hx-swap="afterbegin">
<input name="name" placeholder="Name" required>
<input name="email" placeholder="Email" required>
<button type="submit">Add Contact</button>
</form>
Loading indicators keep users informed during server processing. The hx-indicator attribute references an element that becomes visible during the request, using the htmx-request CSS class that HTMX toggles automatically.
<button hx-post="/process-report"
hx-target="#report-output"
hx-indicator="#processing-spinner">
Generate Report
</button>
<span id="processing-spinner" class="htmx-indicator">
Processing...
</span>
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator { display: inline; }
</style>
For real-time updates that go beyond polling, HTMX integrates with Server-Sent Events through its SSE extension. This enables push-based updates where the server streams HTML fragments to the client as events occur.
<!-- SSE connection for live updates -->
<div hx-ext="sse"
sse-connect="/events/dashboard">
<div sse-swap="stats-update"
hx-swap="innerHTML">
<!-- Updated when server sends "stats-update" event -->
</div>
<div sse-swap="notification"
hx-swap="afterbegin"
hx-target="#notification-list">
<!-- New notifications prepended -->
</div>
</div>
Architecting for Hypermedia
Moving to a hypermedia architecture requires a shift in how you think about your application's structure. Instead of separating frontend and backend into two independent applications connected by a JSON API, you build a single application where the server renders the complete UI, including interactive controls.
The key architectural principle is that the server is the source of truth for both data and presentation state. When a user clicks an "Edit" button, the server returns the edit form pre-populated with current data. When validation fails, the server returns the form with error messages. The client never needs to maintain a shadow copy of the data or implement validation logic.
This has practical implications for project structure. Instead of organizing your codebase into a backend API directory and a frontend application directory, you organize around features, where each feature includes its routes, templates, and business logic together.
# Hypermedia project structure
project/
contacts/
routes.py # HTTP handlers
models.py # Data models
templates/
contacts.html # Full page layout
partials/
list.html # Contact list fragment
row.html # Single contact row
form.html # Add/edit form
search.html # Search input + results
dashboard/
routes.py
templates/
dashboard.html
partials/
stats.html
activity.html
shared/
templates/
layout.html # Base layout with nav, footer
components/
pagination.html
flash.html
This structure mirrors how users interact with your application. Each feature directory contains everything needed to render and update that section of the UI. Shared templates provide the layout shell and reusable components like pagination controls and flash messages.
One common concern is that returning HTML from the server is inefficient compared to JSON. In practice, the difference is negligible for most applications, and HTML responses are often smaller than the equivalent JSON plus the client-side rendering code needed to process it. Server-side template rendering is also extremely fast in modern web frameworks, and HTML responses are highly cacheable. Teams building applications with HTMX often adopt additional patterns for local-first capabilities when offline support is a requirement.
Forms, Validation, and Error Handling
Form handling is where HTMX shines compared to client-side frameworks. Server-side validation means your validation logic exists in exactly one place, and the server returns rendered error messages that are already styled and positioned correctly. There is no need to duplicate validation rules between client and server or manage error state in a client-side store.
# Server-side form handling with validation
@app.route("/contacts", methods=["POST"])
def create_contact():
name = request.form.get("name", "").strip()
email = request.form.get("email", "").strip()
errors = {}
if not name:
errors["name"] = "Name is required"
if not email:
errors["email"] = "Email is required"
elif not is_valid_email(email):
errors["email"] = "Please enter a valid email address"
elif Contact.exists(email=email):
errors["email"] = "A contact with this email already exists"
if errors:
# Return 422 with the form showing errors
return render_template("partials/contact_form.html",
errors=errors,
name=name,
email=email), 422
contact = Contact.create(name=name, email=email)
# Return the new row + out-of-band form reset
response = render_template("partials/contact_row.html",
contact=contact)
response += render_template("partials/contact_form_oob.html")
return response
<!-- partials/contact_form.html -->
<form id="add-contact-form"
hx-post="/contacts"
hx-target="#contact-list tbody"
hx-swap="afterbegin">
<div class="field {% if errors.get('name') %}has-error{% endif %}">
<label for="name">Name</label>
<input id="name" name="name" value="{{ name }}"
required autofocus>
{% if errors.get("name") %}
<span class="error">{{ errors.name }}</span>
{% endif %}
</div>
<div class="field {% if errors.get('email') %}has-error{% endif %}">
<label for="email">Email</label>
<input id="email" name="email" type="email"
value="{{ email }}" required>
{% if errors.get("email") %}
<span class="error">{{ errors.email }}</span>
{% endif %}
</div>
<button type="submit">Add Contact</button>
</form>
The 422 status code is important. HTMX swaps the response for any 2xx status but keeps the existing content for error statuses by default. You can override this behavior with hx-target-422 or by configuring the response target for specific status codes. A common pattern is to target the form itself on error so the entire form including error messages replaces the current form.
For inline field validation, you can use hx-trigger on individual fields to validate as the user types or when a field loses focus.
<input name="email" type="email"
hx-post="/contacts/validate-email"
hx-trigger="blur changed"
hx-target="next .field-feedback"
hx-swap="innerHTML">
<span class="field-feedback"></span>
Testing HTMX Applications
Testing hypermedia applications is simpler than testing SPA applications because you test at the HTTP level. Each endpoint returns HTML, and you can assert on the response content without spinning up a browser or mocking a complex client-side state.
# Python / pytest example
def test_create_contact(client):
response = client.post("/contacts", data={
"name": "Alice Smith",
"email": "[email protected]"
}, headers={"HX-Request": "true"})
assert response.status_code == 200
assert b"Alice Smith" in response.data
assert b"[email protected]" in response.data
def test_create_contact_validation(client):
response = client.post("/contacts", data={
"name": "",
"email": "invalid"
}, headers={"HX-Request": "true"})
assert response.status_code == 422
assert b"Name is required" in response.data
assert b"Please enter a valid email" in response.data
def test_search_contacts(client):
# Seed test data
Contact.create(name="Alice", email="[email protected]")
Contact.create(name="Bob", email="[email protected]")
response = client.get("/contacts?q=alice",
headers={"HX-Request": "true"})
assert response.status_code == 200
assert b"Alice" in response.data
assert b"Bob" not in response.data
def test_delete_contact(client):
contact = Contact.create(name="To Delete",
email="[email protected]")
response = client.delete(f"/contacts/{contact.id}",
headers={"HX-Request": "true"})
assert response.status_code == 200
assert Contact.find(contact.id) is None
For end-to-end testing that verifies HTMX interactions in a real browser, tools like Playwright work well. You can assert that clicking an HTMX-enabled button updates the correct part of the page, that loading indicators appear and disappear, and that form validation messages are displayed correctly.
# Playwright end-to-end test
async def test_inline_edit(page):
await page.goto("/contacts")
# Click edit on a contact row
await page.click("text=Edit", first=True)
# Wait for HTMX to swap in the edit form
await page.wait_for_selector("input[name='name']")
# Modify and save
await page.fill("input[name='name']", "Updated Name")
await page.click("text=Save")
# Verify the row updated
await page.wait_for_selector("text=Updated Name")
Performance and Production Considerations
HTMX applications have several inherent performance advantages. The JavaScript payload is tiny: HTMX itself is around 14KB gzipped, compared to hundreds of kilobytes for a typical React application with its dependencies. There is no client-side hydration step, so pages become interactive immediately after the HTML loads. Server-rendered HTML is also readily cacheable at multiple levels, from browser caches to CDN edges to application-level caches.
However, the hypermedia approach introduces a different performance profile. Every interaction requires a round trip to the server, so network latency directly affects perceived responsiveness. Several strategies mitigate this.
First, use hx-boost on navigation links to convert standard page transitions into AJAX requests that swap only the body content. This eliminates full-page reloads while preserving standard link behavior for users without JavaScript.
<!-- Boost all links in the nav -->
<nav hx-boost="true">
<a href="/contacts">Contacts</a>
<a href="/dashboard">Dashboard</a>
<a href="/settings">Settings</a>
</nav>
Second, use hx-preload (via the preload extension) to fetch content before the user clicks. This starts the request on mouseenter, so by the time the click happens, the response is often already available.
Third, structure your templates so that partial responses are small. A well-designed HTMX endpoint returns only the HTML that changed, not the entire page. This minimizes transfer size and reduces server-side rendering work.
Fourth, implement HTTP caching headers on your partial responses. Template fragments that rarely change can be cached with Cache-Control headers, and conditional requests with ETag or Last-Modified prevent unnecessary rendering.
# Cache a partial response
@app.route("/sidebar/popular")
def popular_sidebar():
articles = Article.popular(limit=10)
response = make_response(
render_template("partials/popular.html",
articles=articles)
)
response.headers["Cache-Control"] = "public, max-age=300"
return response
For applications that need real-time updates, combining HTMX with Server-Sent Events provides a lightweight push mechanism without the complexity of WebSocket infrastructure. The server streams HTML fragments as events, and HTMX swaps them into the appropriate locations on the page. This approach works well for dashboards, notification systems, and collaborative features where eventual consistency is acceptable.
HTMX is not the right choice for every application. Highly interactive interfaces like drag-and-drop builders, real-time collaborative editors, and complex data visualizations still benefit from client-side frameworks. The sweet spot for HTMX is applications where most interactions follow request-response patterns: forms, search, filtering, pagination, CRUD operations, and admin interfaces. For these common patterns, HTMX delivers a simpler architecture, faster initial loads, better accessibility, and dramatically less code to maintain.