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.

Leave a Comment

Your email address will not be published. Required fields are marked *