HTMX with Node.js
Node.js runs JavaScript on the server. Combined with Express — the most popular Node.js web framework — and a templating engine like EJS or Handlebars, you get a fast, flexible server that pairs naturally with HTMX. This topic builds a complete task manager application using Express and HTMX with EJS templates.
Project Setup
mkdir htmx-node-app cd htmx-node-app npm init -y npm install express ejs express-validator csurf cookie-parser
Application Entry Point
// server.js
const express = require('express');
const path = require('path');
const csrf = require('csurf');
const cookieParser = require('cookie-parser');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public'));
app.use(cookieParser());
app.use(csrf({ cookie: true }));
// Make CSRF token available in all templates
app.use((req, res, next) => {
res.locals.csrfToken = req.csrfToken();
next();
});
// Helper: detect HTMX request
app.use((req, res, next) => {
req.isHtmx = req.headers['hx-request'] === 'true';
next();
});
const tasksRouter = require('./routes/tasks');
app.use('/tasks', tasksRouter);
app.listen(3000, () => console.log('Server running at http://localhost:3000'));
In-Memory Data Store (Simple Example)
// data/tasks.js
let tasks = [];
let nextId = 1;
module.exports = {
getAll: () => [...tasks],
getById: (id) => tasks.find(t => t.id === id),
create: (title) => {
const task = { id: nextId++, title, done: false };
tasks.push(task);
return task;
},
delete: (id) => {
tasks = tasks.filter(t => t.id !== id);
},
update: (id, data) => {
const task = tasks.find(t => t.id === id);
if (task) Object.assign(task, data);
return task;
}
};
Routes
// routes/tasks.js
const express = require('express');
const router = express.Router();
const db = require('../data/tasks');
// GET / — task list page
router.get('/', (req, res) => {
const tasks = db.getAll();
if (req.isHtmx) {
return res.render('partials/task_list', { tasks });
}
res.render('index', { tasks });
});
// POST /add — add a new task
router.post('/add', (req, res) => {
const title = (req.body.title || '').trim();
if (!title) {
return res.status(422).render('partials/error', { msg: 'Title is required' });
}
const task = db.create(title);
res.render('partials/task_item', { task });
});
// DELETE /:id — delete a task
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
db.delete(id);
res.status(200).send(''); // Empty response — element removed by outerHTML swap
});
// GET /:id/edit — return edit form
router.get('/:id/edit', (req, res) => {
const task = db.getById(parseInt(req.params.id, 10));
if (!task) return res.status(404).send('Not found');
res.render('partials/task_edit', { task });
});
// PUT /:id — save edited task
router.put('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const title = (req.body.title || '').trim();
const task = db.update(id, { title });
res.render('partials/task_item', { task });
});
module.exports = router;
Views (EJS Templates)
Base Layout: views/layout.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Task Manager</title>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body hx-headers='{"X-CSRF-Token": "<%= csrfToken %>"}'>
<%- body %>
</body>
</html>
Main Page: views/index.ejs
<h2>My Tasks</h2>
<form hx-post="/tasks/add"
hx-target="#task-list"
hx-swap="afterbegin"
hx-on:htmx:after-request="if(event.detail.successful) this.reset()">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="text" name="title" placeholder="New task..." required>
<button type="submit">Add Task</button>
</form>
<ul id="task-list">
<%- include('partials/task_list', { tasks }) %>
</ul>
Partial: views/partials/task_item.ejs
<li id="task-<%= task.id %>">
<span><%= task.title %></span>
<button hx-get="/tasks/<%= task.id %>/edit"
hx-target="#task-<%= task.id %>"
hx-swap="outerHTML">
Edit
</button>
<button hx-delete="/tasks/<%= task.id %>"
hx-target="#task-<%= task.id %>"
hx-swap="outerHTML"
hx-confirm="Delete '<%= task.title %>'?">
Delete
</button>
</li>
Partial: views/partials/task_edit.ejs
<li id="task-<%= task.id %>">
<form hx-put="/tasks/<%= task.id %>"
hx-target="#task-<%= task.id %>"
hx-swap="outerHTML">
<input type="text" name="title" value="<%= task.title %>" required>
<button type="submit">Save</button>
</form>
</li>
Full CRUD flow:
CREATE: Form submit → POST /tasks/add → <li> prepended to list
READ: Page loads → GET /tasks → full list rendered
UPDATE: Edit click → GET /tasks/5/edit → form swapped in
Save click → PUT /tasks/5 → updated <li> swapped back
DELETE: Delete btn → DELETE /tasks/5 → <li> removed
Returning Partial vs Full Page
The req.isHtmx middleware flag makes it easy to return either:
router.get('/', (req, res) => {
const tasks = db.getAll();
if (req.isHtmx) {
// HTMX request — return just the list fragment
return res.render('partials/task_list', { tasks });
}
// Direct browser visit — return the full page
res.render('index', { tasks });
});
Key Takeaway
Express and HTMX integrate cleanly. Detect HTMX requests using the HX-Request header. Pass the CSRF token globally via hx-headers on the body. Use EJS partials for HTML fragments that HTMX swaps into the page. Route each HTTP method to the appropriate CRUD operation. The result is a full-featured, server-rendered application with dynamic page updates — built entirely with HTML attributes and standard Express routes.
