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:
- Browser loads JavaScript bundle
- JavaScript renders the UI
- User interacts → JavaScript makes API call (returns JSON)
- JavaScript transforms JSON → DOM updates
With HTMX, it’s simpler:
- Server renders full HTML page
- User interacts → HTMX makes request (returns HTML fragment)
- 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.
