March 26, 20268 min read

HTMX Explained: Modern Web Apps Without JavaScript Frameworks

How HTMX lets you build dynamic, interactive web applications using HTML attributes instead of React, Vue, or Angular — and when that's actually a good idea.

htmx web-development html javascript-alternatives
Ad 336x280

Somewhere along the way, the web development industry decided that building a web page required downloading 500KB of JavaScript, running a virtual DOM diffing algorithm, and managing state through a unidirectional data flow architecture. HTMX asks a simple question: what if HTML could just do more?

HTMX is a small JavaScript library (14KB gzipped) that extends HTML with attributes for making HTTP requests, handling responses, and updating the DOM. No build step. No JSX. No component lifecycle. No state management library. Just HTML attributes that make your server-rendered pages interactive.

It's either brilliantly simple or dangerously simplistic, depending on what you're building. Let's figure out which.

The Core Idea

In a traditional web app, clicking a link sends a GET request and the browser replaces the entire page. In a React app, a click handler runs JavaScript that fetches JSON from an API, transforms it into a virtual DOM tree, diffs it against the current tree, and patches the real DOM.

HTMX takes the middle path: a click (or any event) sends an HTTP request to the server, the server responds with an HTML fragment, and HTMX swaps that fragment into a specific part of the page. The server does the rendering. The browser does the displaying. Nobody writes a JSON serialization layer.

<!-- A button that loads content without a full page reload -->
<button hx-get="/api/users" hx-target="#user-list" hx-swap="innerHTML">
  Load Users
</button>

<div id="user-list">
<!-- Server-rendered HTML will be inserted here -->
</div>

That's it. Click the button, HTMX sends a GET to /api/users, the server responds with HTML (not JSON), and HTMX puts that HTML inside #user-list. No JavaScript written. No fetch API. No state management.

The Attributes That Matter

HTMX adds a handful of HTML attributes that cover most interactive patterns:

Triggers and requests:
<!-- Any element can make any HTTP request on any event -->
<button hx-post="/items" hx-trigger="click">Add Item</button>
<input hx-get="/search" hx-trigger="keyup changed delay:300ms"
       hx-target="#results" name="q">
<div hx-get="/notifications" hx-trigger="every 30s">
  <!-- Polls for new notifications every 30 seconds -->
</div>
<form hx-put="/users/42" hx-target="#user-card">
  <input name="name" value="Alice">
  <button type="submit">Update</button>
</form>
Targeting and swapping:
<!-- Control where the response goes and how it's inserted -->
<button hx-get="/sidebar" hx-target="#sidebar" hx-swap="outerHTML">
  Refresh Sidebar
</button>

<!-- Swap strategies: innerHTML, outerHTML, beforeend, afterbegin, etc. -->
<button hx-post="/comments" hx-target="#comment-list" hx-swap="beforeend">
Post Comment
</button>

<!-- Target elements elsewhere on the page -->
<button hx-delete="/items/5" hx-target="closest tr" hx-swap="outerHTML">
Delete
</button>

Request indicators and transitions:
<!-- Show a loading indicator while the request is in flight -->
<button hx-get="/slow-data" hx-indicator="#spinner">
  Load Data
</button>
<span id="spinner" class="htmx-indicator">Loading...</span>

<!-- CSS transitions on swap -->
<div id="content" hx-get="/page/2" hx-swap="innerHTML transition:true">
<!-- Content fades in/out on swap -->
</div>

Building Real Features

Let's build some patterns that would traditionally require a JavaScript framework.

Live search with debouncing:
<input type="search" name="q"
       hx-get="/search"
       hx-trigger="input changed delay:300ms, search"
       hx-target="#search-results"
       hx-indicator="#search-spinner"
       placeholder="Search articles...">

<span id="search-spinner" class="htmx-indicator">Searching...</span>
<div id="search-results"></div>

The server endpoint /search receives the query parameter q, runs the search, and returns HTML:

# Flask example
@app.route('/search')
def search():
    query = request.args.get('q', '')
    results = Article.search(query)
    return render_template('partials/search_results.html', results=results)
<!-- partials/search_results.html -->
{% for article in results %}
<div class="search-result">
  <h3><a href="/articles/{{ article.id }}">{{ article.title }}</a></h3>
  <p>{{ article.excerpt }}</p>
</div>
{% empty %}
<p>No results found.</p>
{% endfor %}

That's a complete live search feature. In React, this would require a component with state for the query and results, a useEffect with a debounce hook, a fetch call, JSON parsing, and JSX rendering. Here it's an input attribute and a server template.

Infinite scroll:
<div id="article-list">
  {% for article in articles %}
  <div class="article-card">
    <h3>{{ article.title }}</h3>
    <p>{{ article.summary }}</p>
  </div>
  {% endfor %}

<!-- This last element triggers loading more when it becomes visible -->
<div hx-get="/articles?page={{ next_page }}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-indicator="#load-more-spinner">
<span id="load-more-spinner" class="htmx-indicator">Loading more...</span>
</div>
</div>

When the user scrolls to the bottom, HTMX detects the revealed trigger, fetches the next page, and replaces the trigger element with more articles (plus a new trigger element for the next page). Infinite scroll without a single line of JavaScript.

Inline editing:
<!-- Display mode -->
<div id="user-name">
  <span>Alice Johnson</span>
  <button hx-get="/users/42/edit-name" hx-target="#user-name" hx-swap="outerHTML">
    Edit
  </button>
</div>

Clicking "Edit" fetches an HTML form:

<!-- Edit mode (returned by the server) -->
<form id="user-name" hx-put="/users/42/name" hx-target="#user-name" hx-swap="outerHTML">
  <input name="name" value="Alice Johnson" autofocus>
  <button type="submit">Save</button>
  <button hx-get="/users/42/display-name" hx-target="#user-name" hx-swap="outerHTML">
    Cancel
  </button>
</form>

The form submits via PUT, the server saves the data and returns the display mode HTML. Click-to-edit with zero client-side state management.

Bulk operations with checkboxes:
<form hx-delete="/items/bulk" hx-target="#item-table" hx-confirm="Delete selected items?">
  <button type="submit">Delete Selected</button>
  <table id="item-table">
    <tr>
      <td><input type="checkbox" name="ids" value="1"></td>
      <td>Item One</td>
    </tr>
    <tr>
      <td><input type="checkbox" name="ids" value="2"></td>
      <td>Item Two</td>
    </tr>
  </table>
</form>

HTMX sends the checked values to the server, which deletes them and returns the updated table HTML. The hx-confirm attribute shows a browser confirmation dialog.

Server-Side Integration

HTMX is backend-agnostic. It works with any server that can return HTML. The server needs to do two things differently:

  1. Return HTML fragments, not JSON. Instead of { "users": [...] }, return
  2. Alice
  3. Bob
  4. .
  5. Detect HTMX requests to return fragments vs full pages. HTMX sets the HX-Request: true header, so your server can check that.
# Django example
def user_list(request):
    users = User.objects.all()
    if request.headers.get('HX-Request'):
        # HTMX request — return just the fragment
        return render(request, 'partials/user_list.html', {'users': users})
    # Normal request — return the full page
    return render(request, 'pages/users.html', {'users': users})
// Go example with Chi router
func userList(w http.ResponseWriter, r *http.Request) {
    users := fetchUsers()
    if r.Header.Get("HX-Request") == "true" {
        renderTemplate(w, "partials/user_list.html", users)
        return
    }
    renderTemplate(w, "pages/users.html", users)
}
# Rails example
def index
  @users = User.all
  if request.headers["HX-Request"]
    render partial: "users/list", locals: { users: @users }
  else
    render :index
  end
end

This "return HTML fragments" approach means your server templates are your API. No serialization layer, no OpenAPI spec, no client-side data transformation. The simplicity is real.

When HTMX Is the Right Choice

Content-driven applications. Blogs, CMSes, dashboards, admin panels, e-commerce product pages. Anything where the server already knows how to render the content and you just need some interactivity sprinkled on top. Teams with strong backend skills. If your team is great at Python, Ruby, Go, or PHP but doesn't want to maintain a React frontend, HTMX lets you add interactivity without changing your tech stack. Progressive enhancement. HTMX works well when layered on top of traditional server-rendered pages. The base experience works without JavaScript, and HTMX enhances it. Prototypes and MVPs. When you need to ship fast and don't know if the product will survive, HTMX's simplicity lets you iterate quickly without accumulating frontend complexity. Internal tools. Admin dashboards, back-office tools, data management interfaces — these don't need the polish of a consumer SPA and benefit enormously from reduced complexity.

When HTMX Is the Wrong Choice

Rich client-side interactions. Drag-and-drop interfaces, complex form wizards with client-side validation, real-time collaborative editing, canvas-based applications — these need client-side state and logic that HTMX can't provide. Offline-first applications. HTMX requires a server round-trip for every interaction. If your app needs to work offline, you need client-side state management. Highly dynamic UIs. If a single user action triggers updates in five different parts of the page with complex dependent logic, managing that with HTMX attributes gets messy. React's component model handles this better. Mobile apps and SPAs. HTMX isn't a replacement for React Native. It's not even a great fit for apps that feel like native applications rather than web pages. High-latency environments. Every interaction requires a server round-trip. On fast connections, this is imperceptible. On slow connections, every click has visible latency. SPAs can optimistically update the UI while the request is in flight.

HTMX vs the Frameworks

HTMX vs React/Vue/Angular: These are fundamentally different architectures. The frameworks build a client-side application that communicates with an API via JSON. HTMX extends a server-rendered application with interactive capabilities. For complex SPAs, the frameworks win. For interactive server-rendered pages, HTMX is simpler with fewer moving parts. HTMX vs Hotwire/Turbo: Rails' Turbo does something very similar — server-rendered HTML fragments over the wire. Turbo is more opinionated and tightly integrated with Rails. HTMX is framework-agnostic and more flexible. If you're in Rails, either works. If you're not in Rails, HTMX is the clear choice. HTMX vs Alpine.js: Alpine is "Tailwind for JavaScript" — small, declarative, attribute-based. Alpine handles client-side interactivity (toggle a dropdown, filter a list client-side). HTMX handles server communication. They actually pair well together — HTMX for server interactions, Alpine for client-only logic.

Getting Started

Add a single script tag:

<script src="https://unpkg.com/htmx.org@2.0.0"></script>

That's the entire setup. No npm install. No webpack. No build step.

Start by taking an existing server-rendered page and adding HTMX to one interaction. A delete button that removes a row without a full page reload. A search input that filters results live. A form that submits without navigating away. Each one is a few HTML attributes.

The official documentation at htmx.org is excellent and concise — you can read the entire thing in an hour. The essays section is worth reading too, particularly "Hypermedia as the Engine of Application State" which explains the philosophical foundation.

HTMX won't replace React for complex applications. But it might replace React for applications that never needed React in the first place — and there are a lot of those.

Practice building dynamic, server-driven web applications with modern patterns on CodeUp.

Ad 728x90