HTMX + Alpine.js: The "Perfect" Stack?
The React/Vue/Angular fatigue is real. HTMX + Alpine.js offers an alternative: server-rendered HTML with just enough JavaScript. In 2025, the stack is mature enough for serious use.
The Philosophy
HTMX: Server-Driven Interactions
HTML over the wire. Server returns HTML, not JSON:
<button hx-get="/api/items" hx-target="#items">
Load Items
</button>
<div id="items"></div>
Click → Server returns HTML → Inserted into DOM.
Alpine.js: Client State
Lightweight reactivity for what must be client-side:
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Content</div>
</div>
No build step, no virtual DOM, no framework lock-in.
The Combined Stack
<!-- HTMX handles server communication -->
<div hx-get="/search"
hx-trigger="keyup changed delay:300ms from:#search-input"
hx-target="#results">
<!-- Alpine handles local UI state -->
<div x-data="{ focused: false }">
<input
id="search-input"
type="search"
@focus="focused = true"
@blur="focused = false"
:class="{ 'ring-2': focused }">
</div>
<div id="results"></div>
</div>
HTMX for data. Alpine for UI state.
When This Stack Shines
Content-Heavy Sites
Blogs, documentation, marketing sites, admin panels.
CRUD Applications
Most line-of-business apps are forms and tables:
<!-- Edit in place -->
<tr hx-get="/item/1/edit" hx-trigger="dblclick" hx-swap="outerHTML">
<td>Item Name</td>
<td>$99.99</td>
</tr>
<!-- Returns editable form row -->
<tr>
<td><input name="name" value="Item Name"></td>
<td><input name="price" value="99.99"></td>
<td><button hx-put="/item/1" hx-swap="outerHTML">Save</button></td>
</tr>
Progressive Enhancement
Start with server-rendered HTML, enhance with HTMX:
<!-- Works without JavaScript -->
<form action="/submit" method="POST">
<input name="email">
<button type="submit">Subscribe</button>
</form>
<!-- Enhanced with HTMX -->
<form hx-post="/submit" hx-swap="outerHTML">
<input name="email">
<button type="submit">Subscribe</button>
</form>
Common Patterns
Modal Dialogs
<!-- Trigger -->
<button hx-get="/modal/new-item" hx-target="#modal-container">
New Item
</button>
<!-- Container -->
<div id="modal-container"></div>
<!-- Returned HTML -->
<div x-data="{ open: true }"
x-show="open"
@click.outside="open = false"
class="modal">
<form hx-post="/items" hx-target="#items-list" hx-swap="beforeend">
<input name="name">
<button type="submit" @click="open = false">Create</button>
</form>
</div>
Infinite Scroll
<div id="items">
<!-- Items here -->
<!-- Load more trigger -->
<div hx-get="/items?page=2"
hx-trigger="revealed"
hx-swap="afterend">
Loading more...
</div>
</div>
Tabs
<div x-data="{ tab: 'details' }">
<nav>
<button @click="tab = 'details'" :class="{ active: tab === 'details' }">
Details
</button>
<button @click="tab = 'reviews'" :class="{ active: tab === 'reviews' }">
Reviews
</button>
</nav>
<div x-show="tab === 'details'">
<div hx-get="/product/1/details" hx-trigger="load once">
Loading...
</div>
</div>
<div x-show="tab === 'reviews'">
<div hx-get="/product/1/reviews" hx-trigger="intersect once">
Loading...
</div>
</div>
</div>
Form Validation
<form hx-post="/register" hx-target="this" hx-swap="outerHTML">
<div x-data="{ value: '', valid: false }">
<input
name="email"
type="email"
x-model="value"
x-effect="valid = value.includes('@')"
hx-post="/validate/email"
hx-trigger="change"
hx-target="next .error">
<span class="error"></span>
<span x-show="!valid && value" class="warning">
Please enter a valid email
</span>
</div>
<button type="submit">Register</button>
</form>
Integration with Django
View Pattern
def item_list(request):
items = Item.objects.all()
if request.headers.get('HX-Request'):
# Return partial for HTMX
return render(request, 'partials/item_list.html', {'items': items})
else:
# Return full page
return render(request, 'items/list.html', {'items': items})
django-htmx
def item_detail(request, pk):
item = get_object_or_404(Item, pk=pk)
if request.htmx:
return render(request, 'partials/item_detail.html', {'item': item})
return render(request, 'items/detail.html', {'item': item})
vs React/Vue/Angular
| Aspect | HTMX + Alpine | React/Vue |
|---|---|---|
| Bundle size | ~15KB | 100KB+ |
| Build step | Optional | Required |
| Learning curve | Low | Higher |
| Server coupling | Tight | Loose |
| Offline capability | Limited | Good |
| Complex state | Challenging | Easy |
| SEO | Native | Needs SSR |
| Team skills | Backend devs | Frontend devs |
Limitations
Complex Client State
Multi-step wizards, drag-and-drop, rich text editors—better with React.
Offline-First
HTMX needs the server. For offline apps, use a SPA framework.
Real-Time Collaboration
Google Docs-style apps need client-side state management.
Large Teams
Separation of frontend/backend teams assumes SPA architecture.
The Sweet Spot
HTMX + Alpine works great for:
- Small to medium teams
- Backend-heavy applications
- Content sites with interactivity
- Internal tools and admin panels
- Startups moving fast
It’s not optimal for:
- Figma, Google Docs-class apps
- Offline-first requirements
- Purely client-side experiences
Final Thoughts
HTMX + Alpine isn’t “the future”—it’s a return to web fundamentals with modern ergonomics
For many applications, it’s genuinely the right choice. Less JavaScript, less complexity, faster development.
Try it on your next internal tool. You might be surprised.
Sometimes simpler is actually simpler.