HTMX + Alpine.js: The "Perfect" Stack?

frontend htmx dev

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

<!-- 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

AspectHTMX + AlpineReact/Vue
Bundle size~15KB100KB+
Build stepOptionalRequired
Learning curveLowHigher
Server couplingTightLoose
Offline capabilityLimitedGood
Complex stateChallengingEasy
SEONativeNeeds SSR
Team skillsBackend devsFrontend 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:

It’s not optimal for:

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.

All posts