htmx and Alpine.js: Build Interactive Web Apps Without Heavy JavaScript Frameworks

Tired of shipping megabytes of JavaScript to your users? htmx and Alpine.js let you build highly interactive web applications using HTML attributes and minimal JavaScript. In this tutorial, you'll build a real-time task manager with search, inline editing, and smooth transitions — all without a single bundler or build step.
Learning Objectives
By the end of this tutorial, you will be able to:
- Understand the hypermedia-driven approach with htmx
- Add client-side interactivity with Alpine.js
- Build CRUD operations without writing fetch calls
- Combine htmx and Alpine.js for a powerful, lightweight stack
- Deploy a complete task manager application
Prerequisites
Before starting, ensure you have:
- Node.js 18+ installed (we'll use Express as the backend)
- Basic knowledge of HTML and CSS
- Familiarity with REST APIs concepts
- A code editor (VS Code recommended)
No knowledge of React, Vue, or Angular is required — that's the whole point!
What You'll Build
A fully functional Task Manager application with:
- Real-time search filtering via htmx
- Inline task editing without page reloads
- Drag-and-drop status changes with Alpine.js
- Smooth CSS transitions on content swaps
- Server-side rendering with partial HTML responses
Step 1: Understanding the Hypermedia Approach
Traditional SPAs work like this: the browser downloads a JavaScript bundle, which then fetches JSON from an API and renders the UI client-side. htmx flips this model — the server returns HTML fragments, and htmx swaps them into the DOM.
Traditional SPA:
Browser → JS Bundle → Fetch JSON → Render DOM
htmx approach:
Browser → Click/Input → htmx sends request → Server returns HTML → htmx swaps DOM
This means:
- Less JavaScript shipped to the client
- No client-side state management needed
- Server controls the UI — use any backend language
- Progressive enhancement — works without JS (partially)
Alpine.js complements htmx by handling local UI state — dropdowns, modals, toggles — things that don't need a server round-trip.
Step 2: Project Setup
Create a new project directory and initialize it:
mkdir htmx-task-manager && cd htmx-task-manager
npm init -y
npm install express ejsCreate the project structure:
mkdir -p views/partials public/cssYour directory should look like this:
htmx-task-manager/
├── views/
│ ├── partials/
│ │ ├── task-list.ejs
│ │ ├── task-item.ejs
│ │ └── task-form.ejs
│ └── index.ejs
├── public/
│ └── css/
│ └── styles.css
├── server.js
└── package.json
Step 3: Setting Up the Express Server
Create server.js with the backend logic:
const express = require("express");
const app = express();
app.set("view engine", "ejs");
app.use(express.static("public"));
app.use(express.urlencoded({ extended: true }));
// In-memory task store
let tasks = [
{ id: 1, title: "Learn htmx basics", status: "done", priority: "high" },
{ id: 2, title: "Explore Alpine.js", status: "in-progress", priority: "medium" },
{ id: 3, title: "Build task manager", status: "todo", priority: "high" },
{ id: 4, title: "Add search feature", status: "todo", priority: "low" },
];
let nextId = 5;
// Main page
app.get("/", (req, res) => {
res.render("index", { tasks });
});
// Search tasks — returns HTML partial
app.get("/tasks/search", (req, res) => {
const query = req.query.q?.toLowerCase() || "";
const filtered = tasks.filter((t) =>
t.title.toLowerCase().includes(query)
);
res.render("partials/task-list", { tasks: filtered });
});
// Create task — returns new task HTML
app.post("/tasks", (req, res) => {
const task = {
id: nextId++,
title: req.body.title,
status: "todo",
priority: req.body.priority || "medium",
};
tasks.push(task);
res.render("partials/task-item", { task });
});
// Update task status
app.patch("/tasks/:id/status", (req, res) => {
const task = tasks.find((t) => t.id === parseInt(req.params.id));
if (!task) return res.status(404).send("Task not found");
task.status = req.body.status;
res.render("partials/task-item", { task });
});
// Edit task title (inline)
app.put("/tasks/:id", (req, res) => {
const task = tasks.find((t) => t.id === parseInt(req.params.id));
if (!task) return res.status(404).send("Task not found");
task.title = req.body.title;
res.render("partials/task-item", { task });
});
// Delete task
app.delete("/tasks/:id", (req, res) => {
tasks = tasks.filter((t) => t.id !== parseInt(req.params.id));
res.send("");
});
app.listen(3000, () => {
console.log("Server running at http://localhost:3000");
});Notice how every endpoint returns HTML, not JSON. This is the core principle of htmx.
Step 4: The Main Layout with htmx and Alpine.js
Create views/index.ejs:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Task Manager — htmx + Alpine.js</title>
<!-- htmx from CDN — that's it, no build step -->
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<!-- Alpine.js from CDN -->
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<div class="container" x-data="{ showForm: false }">
<header>
<h1>Task Manager</h1>
<p>Built with htmx + Alpine.js — zero bundlers, zero frameworks</p>
</header>
<!-- Search bar — htmx sends request on every keystroke -->
<div class="search-bar">
<input
type="search"
name="q"
placeholder="Search tasks..."
hx-get="/tasks/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#task-list"
hx-indicator=".search-spinner"
/>
<span class="search-spinner htmx-indicator">Searching...</span>
</div>
<!-- Toggle form with Alpine.js (no server needed) -->
<button @click="showForm = !showForm" class="btn-primary">
<span x-text="showForm ? 'Cancel' : 'New Task'"></span>
</button>
<!-- New task form — Alpine controls visibility, htmx handles submission -->
<div x-show="showForm" x-transition class="task-form">
<%- include('partials/task-form') %>
</div>
<!-- Task list container -->
<div id="task-list">
<%- include('partials/task-list', { tasks }) %>
</div>
</div>
</body>
</html>Let's break down what's happening:
hx-get="/tasks/search"— htmx sends a GET request to this endpointhx-trigger="input changed delay:300ms"— triggers 300ms after the user stops typing (debounced)hx-target="#task-list"— the response HTML replaces the content of#task-listx-data="{ showForm: false }"— Alpine.js local state for the form togglex-show="showForm"— conditionally shows the form, no server round-trip needed
Step 5: Creating the Partials
Task List (views/partials/task-list.ejs)
<div class="task-columns">
<% const statuses = ['todo', 'in-progress', 'done']; %>
<% statuses.forEach(status => { %>
<div class="column">
<h2 class="column-header column-<%= status %>">
<%= status === 'todo' ? 'To Do' : status === 'in-progress' ? 'In Progress' : 'Done' %>
<span class="count">
(<%= tasks.filter(t => t.status === status).length %>)
</span>
</h2>
<div class="task-items">
<% tasks.filter(t => t.status === status).forEach(task => { %>
<%- include('task-item', { task }) %>
<% }) %>
</div>
</div>
<% }) %>
</div>Task Item (views/partials/task-item.ejs)
<div
class="task-card priority-<%= task.priority %>"
id="task-<%= task.id %>"
x-data="{ editing: false }"
>
<!-- View mode -->
<div x-show="!editing">
<div class="task-header">
<span class="task-title"><%= task.title %></span>
<span class="priority-badge"><%= task.priority %></span>
</div>
<div class="task-actions">
<!-- Status cycling with htmx -->
<% if (task.status === 'todo') { %>
<button
hx-patch="/tasks/<%= task.id %>/status"
hx-vals='{"status": "in-progress"}'
hx-target="#task-list"
hx-get="/tasks/search?q="
hx-swap="innerHTML"
class="btn-sm btn-start"
>Start</button>
<% } else if (task.status === 'in-progress') { %>
<button
hx-patch="/tasks/<%= task.id %>/status"
hx-vals='{"status": "done"}'
hx-target="#task-list"
hx-get="/tasks/search?q="
hx-swap="innerHTML"
class="btn-sm btn-done"
>Done</button>
<% } %>
<!-- Inline edit with Alpine.js -->
<button @click="editing = true" class="btn-sm btn-edit">Edit</button>
<!-- Delete with htmx -->
<button
hx-delete="/tasks/<%= task.id %>"
hx-target="#task-<%= task.id %>"
hx-swap="outerHTML"
hx-confirm="Delete this task?"
class="btn-sm btn-delete"
>Delete</button>
</div>
</div>
<!-- Edit mode — Alpine toggles visibility, htmx saves -->
<div x-show="editing" x-transition>
<form
hx-put="/tasks/<%= task.id %>"
hx-target="#task-<%= task.id %>"
hx-swap="outerHTML"
>
<input
type="text"
name="title"
value="<%= task.title %>"
class="edit-input"
@keydown.escape="editing = false"
/>
<div class="edit-actions">
<button type="submit" class="btn-sm btn-save">Save</button>
<button type="button" @click="editing = false" class="btn-sm">Cancel</button>
</div>
</form>
</div>
</div>Task Form (views/partials/task-form.ejs)
<form
hx-post="/tasks"
hx-target="#task-list"
hx-get="/tasks/search?q="
hx-swap="innerHTML"
x-data="{ title: '' }"
>
<div class="form-group">
<input
type="text"
name="title"
placeholder="What needs to be done?"
x-model="title"
required
/>
<select name="priority">
<option value="low">Low</option>
<option value="medium" selected>Medium</option>
<option value="high">High</option>
</select>
<button type="submit" class="btn-primary" :disabled="!title.trim()">
Add Task
</button>
</div>
</form>Step 6: Adding Styles and Transitions
Create public/css/styles.css:
:root {
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--text: #e2e8f0;
--text-muted: #94a3b8;
--primary: #3b82f6;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container {
max-width: 1100px;
margin: 0 auto;
padding: 2rem;
}
header { text-align: center; margin-bottom: 2rem; }
header h1 { font-size: 2rem; }
header p { color: var(--text-muted); }
/* Search */
.search-bar {
position: relative;
margin-bottom: 1.5rem;
}
.search-bar input {
width: 100%;
padding: 0.75rem 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 1rem;
}
.search-spinner {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--primary);
font-size: 0.85rem;
}
/* Columns */
.task-columns {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-top: 1.5rem;
}
.column-header {
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 8px 8px 0 0;
border-bottom: 3px solid;
}
.column-todo { border-color: var(--primary); }
.column-in-progress { border-color: var(--warning); }
.column-done { border-color: var(--success); }
.count { color: var(--text-muted); font-weight: normal; }
/* Task cards */
.task-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-top: 0.75rem;
transition: all 0.2s ease;
}
.task-card:hover { border-color: var(--primary); }
.priority-high { border-left: 3px solid var(--danger); }
.priority-medium { border-left: 3px solid var(--warning); }
.priority-low { border-left: 3px solid var(--success); }
.task-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.priority-badge {
font-size: 0.7rem;
text-transform: uppercase;
padding: 0.15rem 0.5rem;
border-radius: 4px;
background: var(--border);
}
/* Buttons */
.btn-primary {
background: var(--primary);
color: white;
border: none;
padding: 0.5rem 1.25rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.btn-sm {
padding: 0.25rem 0.6rem;
font-size: 0.8rem;
border: 1px solid var(--border);
border-radius: 4px;
background: transparent;
color: var(--text);
cursor: pointer;
}
.btn-start { color: var(--warning); border-color: var(--warning); }
.btn-done { color: var(--success); border-color: var(--success); }
.btn-edit { color: var(--primary); border-color: var(--primary); }
.btn-delete { color: var(--danger); border-color: var(--danger); }
.task-actions { display: flex; gap: 0.5rem; }
/* Edit input */
.edit-input {
width: 100%;
padding: 0.5rem;
background: var(--bg);
border: 1px solid var(--primary);
border-radius: 4px;
color: var(--text);
margin-bottom: 0.5rem;
}
.edit-actions { display: flex; gap: 0.5rem; }
.btn-save { color: var(--success); border-color: var(--success); }
/* Form */
.task-form {
background: var(--surface);
padding: 1rem;
border-radius: 8px;
margin: 1rem 0;
}
.form-group {
display: flex;
gap: 0.75rem;
}
.form-group input {
flex: 1;
padding: 0.5rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
}
.form-group select {
padding: 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
}
/* htmx transitions */
.htmx-swapping { opacity: 0; transition: opacity 0.2s ease-out; }
.htmx-settling { opacity: 1; transition: opacity 0.3s ease-in; }
.htmx-added { opacity: 0; transition: opacity 0.3s ease-in; }
/* htmx indicator */
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
/* Responsive */
@media (max-width: 768px) {
.task-columns { grid-template-columns: 1fr; }
.form-group { flex-direction: column; }
}Step 7: Running the Application
Start the server:
node server.jsOpen http://localhost:3000 in your browser. You should see:
- A search bar — type anything and results filter instantly (htmx sends a request after 300ms of inactivity)
- A "New Task" button — toggles the form using Alpine.js (no network request)
- Task cards with Start/Done/Edit/Delete actions
- Three columns — To Do, In Progress, Done
Step 8: Understanding the htmx + Alpine.js Division
Here's the key architectural insight:
| Concern | Tool | Why |
|---|---|---|
| Data fetching | htmx | Server owns the data, returns HTML |
| Form submission | htmx | Server validates and returns updated UI |
| Search/filter | htmx | Server filters data, returns HTML fragment |
| Toggle visibility | Alpine.js | Pure UI state, no server needed |
| Form validation UI | Alpine.js | Instant feedback, no round-trip |
| Dropdown menus | Alpine.js | Local interaction only |
| Animations | CSS + Alpine.js | x-transition for smooth UI |
Rule of thumb: If it needs data from the server, use htmx. If it's purely visual/interactive, use Alpine.js.
Step 9: Advanced Patterns
Infinite Scroll with htmx
Add pagination to your task list:
<div
hx-get="/tasks?page=2"
hx-trigger="revealed"
hx-swap="afterend"
>
Loading more tasks...
</div>The revealed trigger fires when the element enters the viewport — perfect for infinite scroll.
Optimistic UI with Alpine.js
Show immediate feedback before the server responds:
<button
x-data="{ loading: false }"
@click="loading = true"
:class="loading && 'opacity-50'"
hx-delete="/tasks/1"
hx-on::after-request="loading = false"
>
<span x-show="!loading">Delete</span>
<span x-show="loading">Deleting...</span>
</button>WebSocket Integration
htmx supports WebSocket connections for real-time updates:
<div hx-ext="ws" ws-connect="/ws">
<div id="notifications" hx-swap-oob="beforeend">
<!-- Server pushes HTML here -->
</div>
</div>Out-of-Band Swaps
Update multiple parts of the page from a single response:
<!-- Server response can include out-of-band elements -->
<div id="task-list" hx-swap-oob="true">
<!-- Updated task list -->
</div>
<div id="task-count" hx-swap-oob="true">
<!-- Updated count badge -->
</div>Step 10: Adding a Dark/Light Theme Toggle
This is a perfect Alpine.js use case — no server involved:
<div x-data="{ dark: true }" :class="dark ? 'theme-dark' : 'theme-light'">
<button @click="dark = !dark; localStorage.setItem('theme', dark ? 'dark' : 'light')">
<span x-text="dark ? 'Light Mode' : 'Dark Mode'"></span>
</button>
</div>Alpine.js handles the toggle, CSS handles the styling, and localStorage persists the preference — all without touching the server.
When NOT to Use htmx
htmx isn't the right choice for every project:
- Complex client-side state — If your app needs to manage deeply nested, interconnected state (like a spreadsheet or design tool), a framework like React is more appropriate
- Offline-first apps — htmx requires a server connection for every interaction
- Heavy real-time collaboration — Tools like Google Docs need sophisticated conflict resolution that htmx doesn't provide
- Mobile apps — Use React Native, Flutter, or native SDKs instead
Testing Your Implementation
- Search: Type in the search box — tasks should filter in real-time without page reload
- Create: Click "New Task", fill in the form, submit — the new task should appear in the "To Do" column
- Status change: Click "Start" on a todo task — it should move to "In Progress"
- Inline edit: Click "Edit", modify the title, press Enter — the title should update in place
- Delete: Click "Delete", confirm — the task card should disappear with a smooth transition
- Toggle form: Click "New Task" / "Cancel" — the form should slide in/out without any network request
Troubleshooting
htmx requests not firing
Make sure the htmx.org script is loaded before your HTML elements. Check the browser console for 404 errors on the CDN URL.
Alpine.js directives not working
Ensure the defer attribute is on the Alpine.js script tag. Alpine needs to initialize after the DOM is ready.
Partial responses replacing the whole page
Check your hx-target attribute — it should point to a specific element ID. If omitted, htmx replaces the element that triggered the request.
Performance Comparison
Here's what a typical htmx + Alpine.js app ships vs. a React SPA:
| Metric | htmx + Alpine.js | React SPA |
|---|---|---|
| JS bundle | ~18 KB (gzipped) | ~150-300 KB |
| Time to Interactive | ~200ms | ~1-3s |
| Build step required | No | Yes |
| Server rendering | Native | Requires SSR setup |
| SEO friendly | Yes, by default | Requires Next.js/Remix |
Next Steps
- Explore htmx extensions like
response-targetsandloading-states - Add htmx + SSE for real-time notifications
- Try combining htmx with a Python backend (Django, Flask, FastAPI)
- Look into Alpine.js plugins like
@alpinejs/persistand@alpinejs/intersect - Read the Hypermedia Systems book for deeper understanding
Conclusion
htmx and Alpine.js represent a return to the simplicity of server-rendered web development — but with the interactivity users expect from modern applications. By letting the server handle data and state while using minimal JavaScript for UI interactions, you get applications that are faster, simpler to maintain, and more accessible.
The combination is particularly powerful for:
- CRUD applications (admin dashboards, task managers, CMSs)
- Content-heavy sites that need some interactivity
- Prototypes and MVPs where speed of development matters
- Teams without dedicated frontend developers
Give it a try on your next project — you might be surprised how far you can get without a JavaScript framework.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build Your First AI-Powered Chrome Extension with Manifest V3 and OpenAI
Learn how to build a Chrome extension that uses AI to summarize web pages, generate content, and assist your browsing — step by step with Manifest V3 and the OpenAI API.

Laravel Tutorial Series: Complete Learning Path for PHP Developers
Your comprehensive guide to learning Laravel 11. Follow our structured learning path from PHP basics through advanced features like Stripe integration and AI recommendations.

Building REST APIs with Go and Fiber: A Practical Beginner's Guide
Learn how to build fast, production-ready REST APIs using Go and the Fiber web framework. This step-by-step guide covers project setup, routing, JSON handling, database integration with GORM, middleware, error handling, and testing — from zero to a working API.