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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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 ejs

Create the project structure:

mkdir -p views/partials public/css

Your 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 endpoint
  • hx-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-list
  • x-data="{ showForm: false }" — Alpine.js local state for the form toggle
  • x-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.js

Open http://localhost:3000 in your browser. You should see:

  1. A search bar — type anything and results filter instantly (htmx sends a request after 300ms of inactivity)
  2. A "New Task" button — toggles the form using Alpine.js (no network request)
  3. Task cards with Start/Done/Edit/Delete actions
  4. Three columns — To Do, In Progress, Done

Step 8: Understanding the htmx + Alpine.js Division

Here's the key architectural insight:

ConcernToolWhy
Data fetchinghtmxServer owns the data, returns HTML
Form submissionhtmxServer validates and returns updated UI
Search/filterhtmxServer filters data, returns HTML fragment
Toggle visibilityAlpine.jsPure UI state, no server needed
Form validation UIAlpine.jsInstant feedback, no round-trip
Dropdown menusAlpine.jsLocal interaction only
AnimationsCSS + Alpine.jsx-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

  1. Search: Type in the search box — tasks should filter in real-time without page reload
  2. Create: Click "New Task", fill in the form, submit — the new task should appear in the "To Do" column
  3. Status change: Click "Start" on a todo task — it should move to "In Progress"
  4. Inline edit: Click "Edit", modify the title, press Enter — the title should update in place
  5. Delete: Click "Delete", confirm — the task card should disappear with a smooth transition
  6. 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:

Metrichtmx + Alpine.jsReact SPA
JS bundle~18 KB (gzipped)~150-300 KB
Time to Interactive~200ms~1-3s
Build step requiredNoYes
Server renderingNativeRequires SSR setup
SEO friendlyYes, by defaultRequires Next.js/Remix

Next Steps

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.


Want to read more tutorials? Check out our latest tutorial on Astro 5: Build a Lightning-Fast Content Website with Islands Architecture.

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

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.

30 min read·