HTMX - Old-School Web Dev That Feels Like the Future
Here’s something I wish someone had told me earlier in my career: you don’t need a JavaScript framework for every interactive web app. Sometimes the simplest solution is also the most powerful.
HTMX lets you build modern, dynamic web applications using mostly HTML. No React state management headaches, no Vue composition API confusion, no bundlers, no build steps. Just HTML attributes that make your server-rendered pages feel like a single-page application. And trust me, when you’re shipping features under deadline pressure, simplicity becomes your best friend.
Why HTMX Matters (And Why It’s Not Just Nostalgia)
Yeah, yeah, React and Vue are nice and popular. But here’s the truth: we’ve been overcomplicating things by a long shot. HTMX represents a return to web fundamentals with modern capabilities. It’s not about going backward - it’s about realizing we took a wrong turn somewhere and finding a better path. And, it’s WAY different than anything else.
The JavaScript Framework Problem
Don’t get me wrong - frameworks solve real problems. But they also create problems:
- Massive bundle sizes that slow down your app
- Complex build toolchains that break mysteriously
- State management that requires mental gymnastics
- Learning curves that make onboarding new developers painful
- Client-side rendering that hurts SEO and initial load times
HTMX sidesteps most of these issues by embracing server-side rendering and enhancing it with dynamic behavior.
What HTMX Actually Does
HTMX extends HTML with attributes that let you:
- Make AJAX requests from any element (not just forms)
- Update specific parts of your page without full reloads
- Handle WebSocket and Server-Sent Events
- Trigger requests on various events (click, scroll, load, etc.)
- Swap content into your page with different strategies
Here’s the kicker: you write this functionality in HTML, not JavaScript. Your server returns HTML fragments, not JSON. It’s brilliantly simple.
HTMX Fundamentals Through a Real Example
Let’s build the same task manager we’ve been building with other approaches, but with HTMX. You’ll see why this approach can be refreshingly productive.
Setting Up HTMX
Just add one script tag to your HTML:
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Task Manager - HTMX Edition</title>
7 <script src="https://unpkg.com/htmx.org@1.9.10"></script>
8 <link rel="stylesheet" href="styles.css">
9</head>
10<body>
11 <!-- Your app goes here -->
12</body>
13</html>That’s it. No npm install, no webpack config, no build step. You’re ready to go.
The HTMX Approach: HTML That Makes Requests
Here’s where HTMX gets interesting. Instead of writing JavaScript to handle form submissions, you use HTML attributes:
1<!-- Traditional approach - requires JavaScript -->
2<form id="add-task-form">
3 <input type="text" name="title" required>
4 <button type="submit">Add Task</button>
5</form>
6<script>
7 document.getElementById('add-task-form').addEventListener('submit', async (e) => {
8 e.preventDefault();
9 const formData = new FormData(e.target);
10 const response = await fetch('/tasks', {
11 method: 'POST',
12 body: JSON.stringify(Object.fromEntries(formData))
13 });
14 // ... update the DOM ...
15 });
16</script>
17
18<!-- HTMX approach - just HTML attributes -->
19<form hx-post="/tasks"
20 hx-target="#task-list"
21 hx-swap="afterbegin"
22 hx-on::after-request="this.reset()">
23 <input type="text" name="title" required>
24 <button type="submit">Add Task</button>
25</form>Look at that. No JavaScript. The form POSTs to /tasks, the response (which is HTML) gets inserted at the beginning of #task-list, and the form resets. Done.
Building Our Task Manager with HTMX
Let’s build a complete, interactive task manager. You’ll see how HTMX handles common patterns.
The Complete HTML Structure
1<!DOCTYPE html>
2<html lang="en">
3<head>
4 <meta charset="UTF-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <title>Task Manager - HTMX Edition</title>
7 <script src="https://unpkg.com/htmx.org@1.9.10"></script>
8 <link rel="stylesheet" href="styles.css">
9</head>
10<body>
11 <div class="container">
12 <header>
13 <h1>Task Manager</h1>
14 <div class="stats" id="stats-container">
15 <span id="task-count">0 tasks</span>
16 <span id="completed-count">0 completed</span>
17 </div>
18 </header>
19
20 <!-- Add Task Form -->
21 <form class="add-task-form"
22 hx-post="/tasks"
23 hx-target="#task-list"
24 hx-swap="afterbegin"
25 hx-on::after-request="this.reset()"
26 hx-indicator="#loading">
27
28 <div class="form-group">
29 <input type="text"
30 name="title"
31 placeholder="What needs to be done?"
32 required>
33
34 <select name="priority">
35 <option value="low">Low Priority</option>
36 <option value="medium" selected>Medium Priority</option>
37 <option value="high">High Priority</option>
38 </select>
39 </div>
40
41 <div class="form-group">
42 <textarea name="description"
43 placeholder="Add a description (optional)"
44 rows="2"></textarea>
45 </div>
46
47 <button type="submit">Add Task</button>
48 </form>
49
50 <!-- Filters -->
51 <div class="filters">
52 <button class="filter-btn active"
53 hx-get="/tasks?filter=all"
54 hx-target="#task-list"
55 hx-push-url="true">All</button>
56
57 <button class="filter-btn"
58 hx-get="/tasks?filter=pending"
59 hx-target="#task-list"
60 hx-push-url="true">Pending</button>
61
62 <button class="filter-btn"
63 hx-get="/tasks?filter=completed"
64 hx-target="#task-list"
65 hx-push-url="true">Completed</button>
66 </div>
67
68 <!-- Loading Indicator -->
69 <div id="loading" class="loading htmx-indicator">
70 <div class="spinner"></div>
71 <p>Loading tasks...</p>
72 </div>
73
74 <!-- Task List -->
75 <ul id="task-list"
76 class="task-list"
77 hx-get="/tasks"
78 hx-trigger="load"
79 hx-swap="innerHTML">
80 <!-- Server will render tasks here -->
81 </ul>
82 </div>
83</body>
84</html>Understanding the HTMX Attributes
Let’s break down what’s happening:
hx-post="/tasks" - When the form submits, POST to /tasks endpoint
hx-target="#task-list" - Put the response inside the element with id=“task-list”
hx-swap="afterbegin" - Insert new content at the start of the target (newest first)
hx-on::after-request="this.reset()" - Clear the form after successful submission
hx-indicator="#loading" - Show the loading spinner during the request
hx-get="/tasks" - Make a GET request to fetch tasks
hx-trigger="load" - Trigger the request when the page loads
hx-push-url="true" - Update the browser URL (for proper back/forward button support)
No JavaScript required. Just declarative HTML attributes.
Server-Side: Returning HTML Fragments
Here’s where HTMX’s philosophy really shines. Your server doesn’t return JSON - it returns HTML fragments. This means your templating stays server-side where it’s simpler and more powerful.
Flask Example (Python)
1from flask import Flask, request, render_template_string
2from datetime import datetime
3
4app = Flask(__name__)
5
6# In-memory task storage (use a database in production)
7tasks = []
8task_id_counter = 1
9
10# Template for a single task
11TASK_TEMPLATE = '''
12<li class="task-item {% if task.completed %}completed{% endif %}" id="task-{{ task.id }}">
13 <input type="checkbox"
14 class="task-checkbox"
15 {% if task.completed %}checked{% endif %}
16 hx-post="/tasks/{{ task.id }}/toggle"
17 hx-target="#task-{{ task.id }}"
18 hx-swap="outerHTML">
19
20 <div class="task-content">
21 <div class="task-title">{{ task.title }}</div>
22 {% if task.description %}
23 <div class="task-description">{{ task.description }}</div>
24 {% endif %}
25 <div class="task-meta">
26 <span class="priority-badge priority-{{ task.priority }}">
27 {{ task.priority }}
28 </span>
29 <span>Created {{ task.created_at.strftime('%b %d, %I:%M %p') }}</span>
30 </div>
31 </div>
32
33 <div class="task-actions">
34 <button class="delete-btn"
35 hx-delete="/tasks/{{ task.id }}"
36 hx-target="#task-{{ task.id }}"
37 hx-swap="outerHTML swap:1s"
38 hx-confirm="Are you sure you want to delete this task?">
39 Delete
40 </button>
41 </div>
42</li>
43'''
44
45@app.route('/tasks', methods=['GET'])
46def get_tasks():
47 """Return all tasks or filtered tasks as HTML"""
48 filter_type = request.args.get('filter', 'all')
49
50 filtered_tasks = tasks
51 if filter_type == 'pending':
52 filtered_tasks = [t for t in tasks if not t['completed']]
53 elif filter_type == 'completed':
54 filtered_tasks = [t for t in tasks if t['completed']]
55
56 # Return HTML fragments for each task
57 html_fragments = []
58 for task in filtered_tasks:
59 html_fragments.append(render_template_string(TASK_TEMPLATE, task=task))
60
61 return '\n'.join(html_fragments)
62
63@app.route('/tasks', methods=['POST'])
64def create_task():
65 """Create a new task and return its HTML"""
66 global task_id_counter
67
68 task = {
69 'id': task_id_counter,
70 'title': request.form['title'],
71 'description': request.form.get('description', ''),
72 'priority': request.form['priority'],
73 'completed': False,
74 'created_at': datetime.now()
75 }
76
77 tasks.insert(0, task) # Add to beginning
78 task_id_counter += 1
79
80 # Return HTML for the new task
81 return render_template_string(TASK_TEMPLATE, task=task)
82
83@app.route('/tasks/<int:task_id>/toggle', methods=['POST'])
84def toggle_task(task_id):
85 """Toggle task completion and return updated HTML"""
86 task = next((t for t in tasks if t['id'] == task_id), None)
87
88 if task:
89 task['completed'] = not task['completed']
90 return render_template_string(TASK_TEMPLATE, task=task)
91
92 return '', 404
93
94@app.route('/tasks/<int:task_id>', methods=['DELETE'])
95def delete_task(task_id):
96 """Delete a task and return empty response"""
97 global tasks
98 tasks = [t for t in tasks if t['id'] != task_id]
99
100 # Return empty string - HTMX will remove the element
101 return ''
102
103if __name__ == '__main__':
104 app.run(debug=True)Spring Boot Example (Java)
1@Controller
2@RequestMapping("/tasks")
3public class TaskController {
4
5 private final List<Task> tasks = new ArrayList<>();
6 private final AtomicInteger idCounter = new AtomicInteger(1);
7
8 @GetMapping
9 @ResponseBody
10 public String getTasks(@RequestParam(defaultValue = "all") String filter) {
11 Stream<Task> taskStream = tasks.stream();
12
13 if ("pending".equals(filter)) {
14 taskStream = taskStream.filter(t -> !t.isCompleted());
15 } else if ("completed".equals(filter)) {
16 taskStream = taskStream.filter(Task::isCompleted);
17 }
18
19 return taskStream
20 .map(this::renderTask)
21 .collect(Collectors.joining("\n"));
22 }
23
24 @PostMapping
25 @ResponseBody
26 public String createTask(@RequestParam String title,
27 @RequestParam(required = false) String description,
28 @RequestParam String priority) {
29 Task task = new Task();
30 task.setId(idCounter.getAndIncrement());
31 task.setTitle(title);
32 task.setDescription(description);
33 task.setPriority(priority);
34 task.setCompleted(false);
35 task.setCreatedAt(LocalDateTime.now());
36
37 tasks.add(0, task);
38
39 return renderTask(task);
40 }
41
42 @PostMapping("/{id}/toggle")
43 @ResponseBody
44 public String toggleTask(@PathVariable int id) {
45 Task task = tasks.stream()
46 .filter(t -> t.getId() == id)
47 .findFirst()
48 .orElse(null);
49
50 if (task != null) {
51 task.setCompleted(!task.isCompleted());
52 return renderTask(task);
53 }
54
55 return "";
56 }
57
58 @DeleteMapping("/{id}")
59 @ResponseBody
60 public String deleteTask(@PathVariable int id) {
61 tasks.removeIf(t -> t.getId() == id);
62 return ""; // HTMX will remove the element
63 }
64
65 private String renderTask(Task task) {
66 String completedClass = task.isCompleted() ? "completed" : "";
67 String checked = task.isCompleted() ? "checked" : "";
68 String description = task.getDescription() != null && !task.getDescription().isEmpty()
69 ? "<div class='task-description'>" + task.getDescription() + "</div>"
70 : "";
71
72 return String.format("""
73 <li class="task-item %s" id="task-%d">
74 <input type="checkbox" class="task-checkbox" %s
75 hx-post="/tasks/%d/toggle"
76 hx-target="#task-%d"
77 hx-swap="outerHTML">
78 <div class="task-content">
79 <div class="task-title">%s</div>
80 %s
81 <div class="task-meta">
82 <span class="priority-badge priority-%s">%s</span>
83 <span>Created %s</span>
84 </div>
85 </div>
86 <div class="task-actions">
87 <button class="delete-btn"
88 hx-delete="/tasks/%d"
89 hx-target="#task-%d"
90 hx-swap="outerHTML swap:1s"
91 hx-confirm="Are you sure?">
92 Delete
93 </button>
94 </div>
95 </li>
96 """,
97 completedClass, task.getId(), checked,
98 task.getId(), task.getId(),
99 task.getTitle(),
100 description,
101 task.getPriority(), task.getPriority(),
102 formatDate(task.getCreatedAt()),
103 task.getId(), task.getId()
104 );
105 }
106}Advanced HTMX Patterns
Once you get the basics, HTMX offers some really powerful patterns.
Out-of-Band Swaps (OOB)
Sometimes you want to update multiple parts of the page from a single request. HTMX supports this:
1<!-- When toggling a task, also update the stats -->Server response:
1<!-- Main content that replaces the task -->
2<li class="task-item completed" id="task-1">
3 <!-- task content -->
4</li>
5
6<!-- Out-of-band swap for stats -->
7<div id="stats-container" hx-swap-oob="true">
8 <span>5 tasks</span>
9 <span>2 completed</span>
10</div>HTMX will update both the task AND the stats container from a single response.
Polling for Updates
Need real-time updates? HTMX can poll:
1<ul id="task-list"
2 hx-get="/tasks"
3 hx-trigger="every 5s"
4 hx-swap="innerHTML">
5 <!-- Tasks update every 5 seconds -->
6</ul>Infinite Scroll
Load more tasks as the user scrolls:
1<ul id="task-list">
2 <!-- existing tasks -->
3
4 <li hx-get="/tasks?page=2"
5 hx-trigger="revealed"
6 hx-swap="afterend">
7 <span class="loading">Loading more...</span>
8 </li>
9</ul>When that last <li> becomes visible (revealed), HTMX loads the next page.
Active Search
Live search as the user types:
1<input type="text"
2 name="search"
3 hx-get="/tasks/search"
4 hx-trigger="keyup changed delay:500ms"
5 hx-target="#task-list">This searches 500ms after the user stops typing. No debounce function needed.
When to Use HTMX (And When Not To)
HTMX isn’t the answer for everything. Here’s my honest take on when it shines and when it doesn’t.
HTMX Is Perfect For
Server-rendered applications - If you’re already rendering HTML on the server (Django, Rails, Laravel, Spring MVC), HTMX is a natural fit. You keep your templating logic where it belongs.
Progressive enhancement - Start with a traditional server-rendered app that works without JavaScript, then add HTMX for better UX. If JavaScript fails, your app still works.
Small to medium complexity - CRUD apps, admin panels, dashboards, content management systems. Most web applications fall into this category.
Teams comfortable with backend - If your team is stronger on the backend than frontend, HTMX lets you leverage that strength.
Rapid prototyping - Need to ship features fast? HTMX eliminates the build step and complex state management.
Consider React/Vue Instead When
Highly interactive UIs - Complex drag-and-drop, real-time collaboration, rich text editors. These benefit from sophisticated client-side state management.
Offline functionality - Need the app to work without network? Client-side frameworks handle this better.
Mobile apps - React Native or Vue Native make sense for mobile development.
Large frontend teams - If you have dedicated frontend specialists, a full framework might be worth the complexity.
The HTMX Philosophy: Simplicity as a Feature
Here’s what I love about HTMX: it makes me feel productive. I’m not fighting with build tools or debugging why my state management is out of sync. I write HTML, my server returns HTML, and things just work.
The web started with hypermedia - links and forms that caused the server to send back new HTML. HTMX extends this model instead of replacing it. It’s not about rejecting progress; it’s about questioning whether all that complexity was actually necessary.
Performance Considerations
“But doesn’t server-rendering mean slower responses?” Not really:
- Modern servers render HTML incredibly fast
- You can cache fragments aggressively
- Smaller payloads (HTML vs JSON + rendering code)
- No JavaScript framework overhead on the client
- Progressive rendering as content streams
In my experience, well-built HTMX apps feel snappier than equivalent React apps.
The Learning Curve
Here’s the best part: if you know HTML and can build a basic server-side web app, you already know 80% of what you need for HTMX. There’s no massive ecosystem to learn, no build tools to configure, no competing state management patterns.
You can teach HTMX to a junior developer in an afternoon. Try doing that with React or Vue.
Real Talk: HTMX and Your Career
I won’t sugarcoat this - there are way more React jobs than HTMX jobs right now. If you’re optimizing purely for job opportunities, learn React first.
But here’s the thing: understanding HTMX makes you a better developer regardless of what framework you use. It forces you to think about:
- Server-client architecture
- Network requests and responses
- Progressive enhancement
- When complexity is actually necessary
Plus, the industry is slowly waking up to the idea that maybe we over-complicated things. Projects like HTMX, Phoenix LiveView, and Hotwire (Rails) are gaining serious traction. Early adoption of useful technology is how you stand out.
Try It Yourself
The best way to understand HTMX is to build something with it. Here’s a weekend project:
- Pick a backend framework you know (Flask, Spring Boot, Express, whatever)
- Build a simple CRUD app - tasks, notes, bookmarks, anything
- Start with traditional forms and links
- Add HTMX attributes to make it feel like a SPA
- Compare the code to what you’d write in React
You’ll be surprised how little code you need. And when your colleague asks “Where’s the JavaScript?”, you’ll smile and say “I didn’t need it.”
Resources for Going Deeper
- Official Docs: htmx.org - Excellent examples and reference
- HTMX Essays: The creator’s thoughts on web development philosophy
- Hypermedia Systems (book): Deep dive into the hypermedia approach
- HTMX Discord: Active community of developers using HTMX in production
The Bottom Line
HTMX won’t replace React or Vue anytime soon. But it offers something valuable: a simpler way to build interactive web applications when you don’t need the full complexity of a JavaScript framework.
Not every problem needs a complex solution. Sometimes the old ways, enhanced with modern thinking, are exactly what you need. HTMX proves that you can have modern UX without modern complexity.
Give it a shot on your next project. You might be surprised at how much you can accomplish with just HTML and a few attributes.
Trust me - your future self (and your teammates) will thank you when you’re not debugging webpack configs at 2 AM.