Introduction to Alpine.js and HTMX
Adding interactivity to your web pages doesn’t have to involve complex build systems or large frameworks.
Alpine.js and HTMX provide lightweight, easy-to-use solutions that work directly in the browser using a CDN. Alpine.js focuses on front-end interactivity by allowing you to add declarative behavior and manage state directly in HTML, making it ideal for dynamic UI components. HTMX, on the other hand, is centered on server-driven interactions, letting you make AJAX requests and update HTML content dynamically without writing JavaScript. Together, they provide flexible tools for different kinds of interactivity while avoiding the overhead of larger frameworks or build systems. This makes them perfect for projects that don’t require modern JavaScript build tools.
Why Alpine.js and HTMX?
No Build System Required:
Both libraries work directly in the browser via CDN. No npm, Webpack, Vite, or complex tooling needed.
Progressive Enhancement:
Add interactivity to existing HTML without rewriting everything. They enhance traditional server-rendered applications.
Small Bundle Size:
- Alpine.js: ~15KB gzipped
- HTMX: ~14KB gzipped
- Compare to React (~40KB) + ReactDOM (~130KB)
Simple Learning Curve:
If you know HTML, you can start immediately. No need to learn JSX, virtual DOM, or component lifecycle.
Different Philosophies:
- Alpine.js: “jQuery for the modern web” - frontend reactivity
- HTMX: “Access modern browser features via HTML” - server-driven
When to Use What
Use Alpine.js when:
- You need reactive UI components (dropdowns, modals, tabs)
- State management is mostly client-side
- You want Vue-like syntax without the framework
- Building SPAs with minimal JavaScript
Use HTMX when:
- Your backend renders HTML (Django, Laravel, Rails, PHP)
- You want server-driven interactions
- You prefer server-side logic over client-side
- Building traditional multi-page applications
Use both when:
- You want Alpine for UI interactions + HTMX for server updates
- Building hybrid applications
- You need best of both worlds
Use React/Vue/Svelte when:
- Building complex single-page applications
- Need large ecosystem and community
- Team is already skilled in these frameworks
- Require advanced state management
Alpine.js
Alpine.js brings reactive and declarative behavior to your HTML. Think of it as a lightweight Vue.js that doesn’t require a build step.
Installing Alpine.js
Alpine.js can be added directly to your project using a CDN. There’s no need for npm, Webpack, or other build tools—just include the script tag in your HTML.
Include Alpine.js:
Add this script tag to your HTML file inside the <head> or just before the closing <body> tag:
The defer attribute ensures that Alpine.js runs after your HTML has fully loaded.
Version Pinning
For production, pin to a specific version:
Core Alpine.js Directives
x-data:
Declares a new component scope with reactive data.
x-show / x-if:
Conditionally show/hide elements.
<div x-data="{ show: true }">
<button @click="show = !show">Toggle</button>
<p x-show="show">This text can be toggled</p>
</div>
x-text / x-html:
Set text or HTML content.
x-on (@):
Listen to events.
<button x-on:click="count++">Click me</button>
<!-- Shorthand -->
<button @click="count++">Click me</button>
x-model:
Two-way data binding for form inputs.
<div x-data="{ message: '' }">
<input type="text" x-model="message">
<p x-text="message"></p>
</div>
x-bind (:):
Bind attributes to data.
Basic Alpine.js Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alpine.js Example</title>
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
</head>
<body>
<div x-data="{ count: 0 }">
<button x-on:click="count++">Increase</button>
<button @click="count--">Decrease</button>
<button @click="count = 0">Reset</button>
<p x-text="'You clicked ' + count + ' times.'"></p>
</div>
</body>
</html>
Practical Alpine.js Examples
Dropdown menu:
<div x-data="{ open: false }" @click.away="open = false">
<button @click="open = !open">Menu</button>
<div x-show="open" class="dropdown-menu">
<a href="#">Item 1</a>
<a href="#">Item 2</a>
<a href="#">Item 3</a>
</div>
</div>
Tabs:
<div x-data="{ activeTab: 'home' }">
<div class="tabs">
<button @click="activeTab = 'home'" :class="{ 'active': activeTab === 'home' }">Home</button>
<button @click="activeTab = 'profile'" :class="{ 'active': activeTab === 'profile' }">Profile</button>
<button @click="activeTab = 'settings'" :class="{ 'active': activeTab === 'settings' }">Settings</button>
</div>
<div class="tab-content">
<div x-show="activeTab === 'home'">Home content</div>
<div x-show="activeTab === 'profile'">Profile content</div>
<div x-show="activeTab === 'settings'">Settings content</div>
</div>
</div>
Modal:
<div x-data="{ open: false }">
<button @click="open = true">Open Modal</button>
<div x-show="open" @click="open = false" class="modal-overlay">
<div @click.stop class="modal-content">
<h2>Modal Title</h2>
<p>Modal content goes here</p>
<button @click="open = false">Close</button>
</div>
</div>
</div>
Form with validation:
<div x-data="{
email: '',
password: '',
errors: {}
}">
<form @submit.prevent="
errors = {};
if (!email.includes('@')) errors.email = 'Invalid email';
if (password.length < 6) errors.password = 'Password too short';
if (Object.keys(errors).length === 0) alert('Form valid!');
">
<input type="email" x-model="email" placeholder="Email">
<p x-show="errors.email" x-text="errors.email" class="error"></p>
<input type="password" x-model="password" placeholder="Password">
<p x-show="errors.password" x-text="errors.password" class="error"></p>
<button type="submit">Submit</button>
</form>
</div>
Alpine.js with Components
For reusable logic, extract into Alpine components:
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() {
this.open = !this.open;
},
close() {
this.open = false;
}
}));
});
</script>
<div x-data="dropdown" @click.away="close()">
<button @click="toggle()">Menu</button>
<div x-show="open">
<a href="#">Item 1</a>
<a href="#">Item 2</a>
</div>
</div>
HTMX
HTMX extends HTML with attributes that let you access modern browser features like AJAX, CSS Transitions, WebSockets, and Server-Sent Events directly from HTML, without writing JavaScript.
Installing HTMX
HTMX is equally simple to set up. Like Alpine.js, it works directly in the browser via a CDN.
Include HTMX:
Add this script tag to your HTML file:
This is all you need to start using HTMX attributes in your HTML.
Core HTMX Attributes
hx-get / hx-post / hx-put / hx-delete:
Make HTTP requests.
hx-target:
Specifies which element to update with the response.
hx-swap:
Determines how the response is inserted:
innerHTML(default) - Replace inner HTMLouterHTML- Replace entire elementbeforebegin- Insert before elementafterbegin- Insert at start of elementbeforeend- Insert at end of elementafterend- Insert after elementdelete- Delete the target elementnone- Don’t insert anything
hx-trigger:
Specifies what event triggers the request.
hx-indicator:
Shows a loading indicator during the request.
<button hx-get="/data" hx-indicator="#spinner">Load</button>
<div id="spinner" class="htmx-indicator">Loading...</div>
Basic HTMX Example
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTMX Example</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<button hx-get="/hello.html" hx-target="#content" hx-swap="innerHTML">
Load Content
</button>
<div id="content"></div>
</body>
</html>
hello.html:
In this example:
hx-getsends a GET request to/hello.htmlhx-targetspecifies which element to update (#content)hx-swapdetermines how the response is inserted (replaces inner HTML)
The server response can be plain HTML fragments.
Practical HTMX Examples
Live search:
<input
type="search"
name="q"
hx-get="/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#search-results"
placeholder="Search..."
>
<div id="search-results"></div>
Server response (/search?q=term):
Delete item with confirmation:
<button
hx-delete="/api/items/123"
hx-confirm="Are you sure you want to delete this item?"
hx-target="closest .item"
hx-swap="outerHTML"
>
Delete
</button>
Infinite scroll:
<div id="content">
<!-- Initial items -->
<div class="item">Item 1</div>
<div class="item">Item 2</div>
<!-- Load more trigger -->
<div
hx-get="/api/items?page=2"
hx-trigger="revealed"
hx-swap="afterend"
>
<span class="loading">Loading more...</span>
</div>
</div>
Form submission:
<form hx-post="/api/contact" hx-target="#form-result">
<input type="text" name="name" placeholder="Name" required>
<input type="email" name="email" placeholder="Email" required>
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send</button>
</form>
<div id="form-result"></div>
Server success response:
Server error response (with 4xx or 5xx status):
Polling for updates:
<div
hx-get="/api/notifications"
hx-trigger="every 5s"
hx-target="this"
hx-swap="innerHTML"
>
<!-- Notifications will be updated every 5 seconds -->
</div>
File upload with progress:
<form hx-post="/upload" hx-encoding="multipart/form-data">
<input type="file" name="file">
<button type="submit">Upload</button>
<div class="htmx-indicator">
<progress></progress>
</div>
</form>
Combining Alpine.js and HTMX
Alpine.js and HTMX work great together:
HTMX for server interactions, Alpine for UI:
<div x-data="{ showDetails: false }">
<!-- HTMX loads content from server -->
<button
hx-get="/user/123"
hx-target="#user-data"
@click="showDetails = true"
>
Load User
</button>
<!-- Alpine controls visibility -->
<div id="user-data" x-show="showDetails"></div>
<button @click="showDetails = false" x-show="showDetails">Hide</button>
</div>
Dynamic tabs with server-loaded content:
<div x-data="{ activeTab: 'home' }">
<div class="tabs">
<button
@click="activeTab = 'home'"
hx-get="/tabs/home"
hx-target="#tab-content"
:class="{ 'active': activeTab === 'home' }"
>
Home
</button>
<button
@click="activeTab = 'profile'"
hx-get="/tabs/profile"
hx-target="#tab-content"
:class="{ 'active': activeTab === 'profile' }"
>
Profile
</button>
</div>
<div id="tab-content"></div>
</div>
Comparison with Other Solutions
| Feature | Alpine.js | HTMX | jQuery | React | Vue |
|---|---|---|---|---|---|
| Bundle Size | ~15KB | ~14KB | ~30KB | ~170KB | ~40KB |
| Build Required | ❌ | ❌ | ❌ | ✅ | Optional |
| Learning Curve | Easy | Easy | Easy | Steep | Medium |
| State Management | Simple | Server | Manual | Complex | Built-in |
| Server Interaction | Manual | Built-in | Manual | Manual | Manual |
| Best For | UI interactions | Server-driven | Legacy | SPAs | SPAs |
Backend Setup for HTMX
HTMX works with any backend that can serve HTML. Here are examples:
Node.js/Express:
app.get('/hello.html', (req, res) => {
res.send('<h1>Hello from Express!</h1>');
});
app.post('/api/contact', (req, res) => {
// Process form
res.send('<div class="success">Message sent!</div>');
});
Python/Flask:
@app.route('/hello.html')
def hello():
return '<h1>Hello from Flask!</h1>'
@app.route('/api/contact', methods=['POST'])
def contact():
# Process form
return '<div class="success">Message sent!</div>'
PHP:
<?php
// hello.php
echo '<h1>Hello from PHP!</h1>';
// contact.php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// Process form
echo '<div class="success">Message sent!</div>';
}
Development Tips
Alpine.js tips:
- Keep components small and focused
- Use
Alpine.data()for reusable components - Use
x-cloakto prevent flash of unstyled content - Leverage
@click.awayfor dropdowns and modals - Use
x-iffor expensive DOM operations,x-showfor simple toggling
HTMX tips:
- Return HTML fragments, not full pages
- Use appropriate HTTP status codes (200, 400, 500, etc.)
- Leverage
hx-swapfor different insertion behaviors - Use
hx-push-urlto update browser history - Add
hx-indicatorfor better UX during requests - Use
hx-triggermodifiers likechanged,delay,throttle
Both:
- Start simple - add complexity only when needed
- Use browser DevTools to debug
- Read the documentation - both have excellent docs
- Combine with Tailwind CSS for rapid styling
Summary
Alpine.js and HTMX offer lightweight, no-build-step solutions for adding interactivity to web applications, each with a different philosophy.
Key Takeaways
Alpine.js:
- Lightweight reactive framework (~15KB)
- Vue-like syntax without build step
- Perfect for UI interactions (dropdowns, modals, tabs)
- Client-side state management
- Works directly via CDN
- “jQuery for the modern web”
HTMX:
- HTML-first approach (~14KB)
- Makes AJAX requests via HTML attributes
- Server-driven interactions
- Progressive enhancement
- Works with any backend
- “Access modern browser features via HTML”
When to Use:
- Alpine.js: Client-side interactivity, reactive components
- HTMX: Server-rendered apps, traditional backends
- Both together: Hybrid approach - UI with Alpine, data with HTMX
- React/Vue: Complex SPAs, large teams, advanced state management
Advantages:
- No build system required
- Small bundle size
- Easy learning curve
- Progressive enhancement
- Works with existing HTML
- CDN-ready
Best Practices:
- Keep Alpine components small and focused
- Return HTML fragments from HTMX endpoints
- Use appropriate HTTP methods and status codes
- Combine with Tailwind CSS for styling
- Start simple, add complexity as needed
Backend Compatibility:
- Works with any server that returns HTML
- Node.js, Python, PHP, Ruby, Go, .NET, etc.
- Perfect for traditional server-rendered applications
- No API serialization overhead
Comparison to Frameworks:
- Much lighter than React/Vue (15KB vs 170KB)
- No build step unlike modern frameworks
- Simpler than jQuery with modern syntax
- Different philosophy - enhancement vs replacement
Alpine.js and HTMX represent a modern approach to web development that embraces HTML and progressive enhancement while avoiding the complexity of large frameworks and build systems. They’re excellent choices for projects that want modern interactivity without the overhead.