Six months ago, I rebuilt a React admin dashboard with HTMX. The original was 2.3MB of JavaScript, had 847 npm dependencies, took 18 seconds to build, and needed a dedicated frontend developer to maintain. The HTMX version? 14KB of JavaScript, zero build step, and any backend developer on the team can work on it.

I’m not saying HTMX replaces React for everything. But for a large category of web applications, we’ve been massively over-engineering with SPAs when server-rendered HTML with sprinkles of interactivity is all we needed.

What Is HTMX, Actually?

HTMX is a 14KB JavaScript library that lets you access modern browser features directly from HTML. Instead of writing JavaScript to make API calls and update the DOM, you add attributes to your HTML elements:

<!-- This button makes a POST request and swaps the response into #results -->
<button hx-post="/api/search" 
        hx-target="#results" 
        hx-swap="innerHTML"
        hx-include="[name='query']">
  Search
</button>

<div id="results">
  <!-- Server returns HTML that gets injected here -->
</div>

That’s it. No useState, no useEffect, no state management library, no build step. The server returns HTML, and HTMX puts it where you tell it to.

The Mental Model Shift

With React/Vue/Svelte, the architecture looks like:

  1. Browser loads JavaScript bundle
  2. JavaScript renders the UI
  3. User interacts → JavaScript makes API call (returns JSON)
  4. JavaScript transforms JSON → DOM updates

With HTMX, it’s simpler:

  1. Server renders full HTML page
  2. User interacts → HTMX makes request (returns HTML fragment)
  3. HTMX swaps the fragment into the page

The server is doing what servers have always done — rendering HTML. You’re just doing partial page updates instead of full page reloads.

Pro Tip: Think of HTMX as “AJAX without JavaScript.” Every interaction that would normally require a fetch() call and DOM manipulation can be expressed as HTML attributes instead.

Real-World Example: A Complete CRUD Interface

Here’s a task management interface built entirely with HTMX:

<!-- Task list page -->
<div id="task-list">
  <form hx-post="/tasks" 
        hx-target="#task-list" 
        hx-swap="afterbegin"
        hx-on::after-request="this.reset()">
    <input type="text" name="title" placeholder="New task..." required>
    <button type="submit">Add</button>
  </form>

  <div id="tasks">
    <!-- Each task is a self-contained component -->
    <div class="task" id="task-1">
      <input type="checkbox" 
             hx-patch="/tasks/1/toggle"
             hx-target="#task-1"
             hx-swap="outerHTML">
      <span>Build the dashboard</span>
      <button hx-delete="/tasks/1" 
              hx-target="#task-1" 
              hx-swap="outerHTML"
              hx-confirm="Delete this task?">
        ×
      </button>
    </div>
  </div>
</div>

And the server side (Express.js):

// Server returns HTML fragments, not JSON
app.post('/tasks', async (req, res) => {
  const task = await db.tasks.create({ title: req.body.title });
  res.send(`
    <div class="task" id="task-${task.id}">
      <input type="checkbox" 
             hx-patch="/tasks/${task.id}/toggle"
             hx-target="#task-${task.id}"
             hx-swap="outerHTML">
      <span>${task.title}</span>
      <button hx-delete="/tasks/${task.id}" 
              hx-target="#task-${task.id}" 
              hx-swap="outerHTML"
              hx-confirm="Delete this task?">×</button>
    </div>
  `);
});

app.delete('/tasks/:id', async (req, res) => {
  await db.tasks.delete(req.params.id);
  res.send('');  // Empty response = element is removed
});

app.patch('/tasks/:id/toggle', async (req, res) => {
  const task = await db.tasks.toggle(req.params.id);
  res.send(`
    <div class="task ${task.done ? 'done' : ''}" id="task-${task.id}">
      <input type="checkbox" ${task.done ? 'checked' : ''}
             hx-patch="/tasks/${task.id}/toggle"
             hx-target="#task-${task.id}"
             hx-swap="outerHTML">
      <span>${task.title}</span>
      <button hx-delete="/tasks/${task.id}" 
              hx-target="#task-${task.id}" 
              hx-swap="outerHTML"
              hx-confirm="Delete this task?">×</button>
    </div>
  `);
});

HTMX Core Attributes

Attribute Purpose Example
hx-get GET request hx-get="/api/users"
hx-post POST request hx-post="/api/users"
hx-put PUT request hx-put="/api/users/1"
hx-patch PATCH request hx-patch="/api/users/1"
hx-delete DELETE request hx-delete="/api/users/1"
hx-target Where to put response hx-target="#results"
hx-swap How to insert response hx-swap="innerHTML"
hx-trigger What triggers the request hx-trigger="click"
hx-indicator Loading indicator hx-indicator="#spinner"
hx-confirm Confirmation dialog hx-confirm="Are you sure?"
hx-push-url Update browser URL hx-push-url="true"

Swap Strategies

<!-- Replace inner content -->
<div hx-swap="innerHTML">...</div>

<!-- Replace entire element -->
<div hx-swap="outerHTML">...</div>

<!-- Insert before/after -->
<div hx-swap="beforeend">...</div>
<div hx-swap="afterbegin">...</div>

<!-- Delete the element -->
<div hx-swap="delete">...</div>

<!-- With transitions -->
<div hx-swap="innerHTML transition:true">...</div>

Performance: HTMX vs React SPA

Here’s my real comparison from the admin dashboard rebuild:

Metric React SPA HTMX + Server HTML
Initial JS bundle 2.3 MB 14 KB
Time to Interactive 4.2s 0.8s
Time to First Byte 200ms 180ms
Subsequent navigation 100-300ms 80-200ms
Memory usage 45-80 MB 12-18 MB
Build time 18s 0s (no build)
npm dependencies 847 3 (express, htmx, template engine)
Lines of JavaScript 12,400 340

The numbers speak for themselves, but context matters. This was an internal admin dashboard — forms, tables, CRUD operations. Not a real-time collaborative editor or a complex data visualization tool.

Pro Tip: HTMX is perfect for web performance because you’re sending minimal JavaScript. Core Web Vitals improve dramatically when you’re not shipping a 2MB React bundle for what’s essentially a server-rendered application with some interactivity.

Advanced Patterns

Infinite Scroll

<div id="feed">
  <!-- Existing items -->
  <div class="item">Post 1</div>
  <div class="item">Post 2</div>
  
  <!-- Trigger loads more when scrolled into view -->
  <div hx-get="/feed?page=2" 
       hx-trigger="revealed" 
       hx-swap="outerHTML"
       hx-indicator="#loading">
    <span id="loading" class="htmx-indicator">Loading...</span>
  </div>
</div>

Active Search (Debounced)

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

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

Polling for Real-Time Updates

<!-- Poll every 5 seconds for new notifications -->
<div hx-get="/notifications/count" 
     hx-trigger="every 5s"
     hx-swap="innerHTML">
  <span class="badge">3</span>
</div>

Form Validation

<!-- Validate email on blur -->
<input type="email" 
       name="email"
       hx-get="/validate/email"
       hx-trigger="blur changed"
       hx-target="next .error"
       hx-params="email">
<span class="error"></span>

Modals and Dialogs

<!-- Load modal content from server -->
<button hx-get="/users/1/edit" 
        hx-target="#modal-container"
        hx-swap="innerHTML">
  Edit User
</button>

<div id="modal-container"></div>

Server returns the full modal markup:

<!-- Server response for /users/1/edit -->
<dialog open>
  <form hx-put="/users/1" hx-target="#user-1" hx-swap="outerHTML">
    <input name="name" value="Michael">
    <input name="email" value="[email protected]">
    <button type="submit">Save</button>
    <button type="button" onclick="this.closest('dialog').close()">Cancel</button>
  </form>
</dialog>

HTMX with Different Backend Frameworks

Python (Flask)

@app.route('/tasks/<int:id>', methods=['DELETE'])
def delete_task(id):
    db.session.delete(Task.query.get(id))
    db.session.commit()
    return ''  # Empty = element removed

@app.route('/tasks/<int:id>/edit', methods=['GET'])
def edit_task_form(id):
    task = Task.query.get(id)
    return render_template('partials/task_edit.html', task=task)

Go (Chi)

func deleteTask(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    db.Delete(&Task{}, id)
    w.WriteHeader(200)
    // Empty body = element removed
}

Ruby (Sinatra/Rails)

delete '/tasks/:id' do
  Task.find(params[:id]).destroy
  ''  # Empty response
end

When HTMX Wins vs When React Wins

Use Case HTMX React/SPA
Admin dashboards ✅ Perfect ⚠️ Overkill
E-commerce sites ✅ Great ⚠️ Usually overkill
Blog/CMS ✅ Perfect ❌ Way overkill
Collaborative editors ❌ Not ideal ✅ Better choice
Real-time chat ⚠️ Possible with SSE ✅ Better DX
Data visualization ❌ Limited ✅ Needed
Mobile app (PWA) ⚠️ Limited ✅ Better offline
Internal tools ✅ Perfect ⚠️ Usually overkill
Marketing sites ✅ Perfect ❌ Overkill
Complex forms (wizard) ✅ Great ✅ Also fine

Common Mistakes with HTMX

1. Thinking in SPA Terms

The biggest mistake is trying to replicate SPA patterns. With HTMX, you don’t need client-side state management. The server IS your state. Every request returns the current state as HTML.

2. Over-Engineering the HTML Responses

<!-- ❌ Don't return huge page sections -->
<!-- Return the smallest HTML fragment that makes sense -->

<!-- ✅ Good: Replace just the changed element -->
<div id="user-status" hx-swap-oob="true">Online</div>

3. Not Using hx-swap-oob for Multi-Target Updates

Out-of-band swaps update multiple elements from a single response:

<!-- Server response can update multiple targets -->
<div id="task-list"><!-- Updated task list --></div>
<div id="task-count" hx-swap-oob="true">Tasks: 5</div>
<div id="notification" hx-swap-oob="true">Task created!</div>

4. Forgetting Loading States

<!-- Always add loading indicators for better UX -->
<button hx-post="/slow-action" hx-indicator="#spinner">
  Submit
  <span id="spinner" class="htmx-indicator">⏳</span>
</button>

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

5. Not Considering Progressive Enhancement

HTMX works great with progressive enhancement — forms submit normally without JavaScript, and HTMX enhances the experience:

<!-- Works without JS (full page submit), enhanced with HTMX -->
<form action="/search" method="get"
      hx-get="/search" 
      hx-target="#results"
      hx-push-url="true">
  <input name="q" type="search">
  <button type="submit">Search</button>
</form>

HTMX + Tailwind CSS: The Pragmatic Stack

For modern styling with Tailwind v4, HTMX pairs beautifully. No component library needed — just HTML with utility classes:

<button hx-post="/subscribe"
        hx-target="this"
        hx-swap="outerHTML"
        class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">
  Subscribe
</button>

<!-- Server responds with: -->
<span class="text-green-600 font-medium">✓ Subscribed!</span>

My Stack for HTMX Projects

  • Runtime: Node.js or Bun
  • Framework: Express or Hono
  • Templates: EJS or Handlebars (server-side rendering)
  • Styling: Tailwind CSS
  • Database: PostgreSQL with Drizzle ORM
  • Deployment: Docker on Railway or Fly.io

Total JS shipped to client: HTMX (14KB) + Alpine.js (17KB) for complex interactions = 31KB. Compare that to a typical React app at 200-500KB minimum.

FAQ

Is HTMX production-ready?

Absolutely. HTMX has been stable since 2020 (originally intercooler.js since 2013). Companies like GitHub, Cloudflare, and many others use hypermedia-driven approaches in production. The library is small, well-tested, and actively maintained with semantic versioning.

Can I use HTMX with React/Next.js?

Technically yes, but it defeats the purpose. HTMX’s value is eliminating the need for a client-side framework. If you’re already committed to React, look at React Server Components instead — they share the philosophy of moving logic to the server. See our RSC guide.

How does HTMX handle SEO?

HTMX is inherently SEO-friendly because your pages are server-rendered HTML. Search engines see fully rendered content on the first request — no JavaScript execution needed. This is one of HTMX’s biggest advantages over SPAs, which require SSR/SSG hacks to be SEO-friendly.

What about offline support and PWAs?

This is HTMX’s weakness. Since every interaction requires a server request, offline support is limited. You can use Service Workers to cache HTML responses, but it’s not as seamless as a fully client-side app. If offline functionality is critical, an SPA is the better choice.

How do I handle complex client-side state with HTMX?

For simple state (toggles, selections), use CSS classes and data attributes. For moderate complexity, add Alpine.js (17KB) alongside HTMX. For truly complex client-side state (drag-and-drop, real-time collaboration), you’ve probably hit the boundary where HTMX isn’t the right tool — use an SPA framework for that specific feature.