---
title: 'htmx Tutorial: TypeScript Express Live Kanban (2026)'
description: Build a production htmx kanban with TypeScript, Express 5, and SSE live updates. 2026 step-by-step tutorial—real code, no React, every version pinned.
url: https://nerdleveltech.com/htmx-typescript-express-live-kanban-tutorial
language: en
date: '2026-05-09'
category: backend
tags:
  - htmx
  - typescript
  - express
  - server-sent events
  - kanban tutorial
  - hypermedia
  - node.js
markdown_url: https://nerdleveltech.com/htmx-typescript-express-live-kanban-tutorial.md
attribution: 'Source: NerdLevelTech — https://nerdleveltech.com/htmx-typescript-express-live-kanban-tutorial'
terms: https://nerdleveltech.com/llms.txt
---

# htmx Tutorial: TypeScript Express Live Kanban (2026)

## TL;DR

You will build a fully functional, live-updating kanban board using **htmx 2.0.10**, **TypeScript 6.0.3**, **Express 5.2.1**, and **better-sqlite3 12.9.0** on **Node.js 24 LTS** — without a single line of client-side JavaScript framework code. The server returns HTML fragments, htmx swaps them into the DOM, and **Server-Sent Events** push updates to every connected browser when any user moves a card. Total app: roughly 200 lines of TypeScript across four source files, runnable in 15 minutes.

This htmx tutorial focuses on TypeScript and Express because the existing 2026 ecosystem leans heavily toward Python/Flask or Go demos. If your team already runs Node.js, this is the stack you want — type-safe routes returning hypermedia, SSE for live updates without WebSockets, and SQLite so you don't need to provision a database to follow along. Every version below is pinned and verified against the official registry on the day this was written.

## What you will learn

- How to scaffold a typed htmx + Express 5 project with `tsx` for fast feedback
- How to model a kanban board in SQLite with `better-sqlite3` synchronous transactions
- How to render initial HTML and **HTML fragments** for `hx-get`, `hx-post`, `hx-put`, and `hx-delete`
- How to use **`hx-swap-oob`** (out-of-band swaps) to update multiple unrelated DOM regions from a single response
- How to add live multi-user updates using the **htmx SSE extension** and the native `EventSource` API
- How to harden routes with **server-side validation**, structured error fragments, and an htmx-aware error path
- How to verify everything with `curl` so you trust the wire format before the browser ever opens

## Prerequisites

| Tool | Version | Reason |
|---|---|---|
| Node.js | 24.15.0 (Active LTS, April 2026)[^1] | Express 5 needs Node 18+; LTS gets security patches through April 2028 |
| npm | 10.x (bundled with Node 24) | Package install |
| SQLite | bundled in `better-sqlite3` 12.9.0 | No separate install needed |
| A modern browser | Chrome 120+ / Firefox 128+ / Safari 17+ | Native `EventSource` and modern CSS Grid |

This tutorial does not use Docker. SQLite gives you a zero-setup database, and `better-sqlite3` ships its own SQLite — pull the repo, `npm install`, run, done.

## Step 1 — Scaffold the project

Create the directory and the bare TypeScript project. We use `tsx` (a thin esbuild wrapper) instead of `ts-node` because it runs ESM natively and is dramatically faster for the dev loop.[^2]

```bash
mkdir htmx-kanban && cd htmx-kanban
npm init -y
npm pkg set type=module
npm install express@5.2.1 better-sqlite3@12.9.0
npm install -D typescript@6.0.3 tsx@4.21.0 @types/node@24.0.0 @types/express@5.0.6 @types/better-sqlite3@7.6.13
```

Pin versions exactly. The 2026 npm ecosystem moves fast, and a tutorial that breaks the moment a transitive `^` major-bumps is not a tutorial. If you read this later than May 2026, run `npm view <pkg> version` for each line above and either pin to those values or update consciously.

Create `tsconfig.json`:

```json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2023"],
    "outDir": "dist",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*.ts"]
}
```

`noUncheckedIndexedAccess` is the single most useful strict flag for this codebase — it forces you to handle missing rows from SQLite queries explicitly.

Add scripts to `package.json`:

```json
{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}
```

Create the source folders: `mkdir -p src public`.

## Step 2 — Model the kanban board in SQLite

A kanban board is two tables: columns and cards. We hard-code the three columns (`todo`, `doing`, `done`) so we don't have to build column-management UI, and we keep cards in a single table with a `column` discriminator and a `sort_order` integer.

Create `src/db.ts`:

```ts
import Database from "better-sqlite3";

export type Column = "todo" | "doing" | "done";
export interface Card {
  id: number;
  title: string;
  column: Column;
  sort_order: number;
  created_at: string;
}

const db = new Database("kanban.db");
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

db.exec(`
  CREATE TABLE IF NOT EXISTS cards (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    title       TEXT    NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
    "column"    TEXT    NOT NULL CHECK ("column" IN ('todo', 'doing', 'done')),
    sort_order  INTEGER NOT NULL DEFAULT 0,
    created_at  TEXT    NOT NULL DEFAULT (datetime('now'))
  );
  CREATE INDEX IF NOT EXISTS idx_cards_column_sort ON cards("column", sort_order);
`);

export const queries = {
  listByColumn: db.prepare<[Column], Card>(
    `SELECT * FROM cards WHERE "column" = ? ORDER BY sort_order, id`
  ),
  insert: db.prepare<[string, Column, number]>(
    `INSERT INTO cards (title, "column", sort_order) VALUES (?, ?, ?)`
  ),
  getById: db.prepare<[number], Card>(`SELECT * FROM cards WHERE id = ?`),
  move: db.prepare<[Column, number]>(
    `UPDATE cards SET "column" = ? WHERE id = ?`
  ),
  remove: db.prepare<[number]>(`DELETE FROM cards WHERE id = ?`),
  maxSort: db.prepare<[Column], { m: number | null }>(
    `SELECT COALESCE(MAX(sort_order), -1) AS m FROM cards WHERE "column" = ?`
  ),
};

export default db;
```

Three things worth noting. First, `column` is a reserved word in SQL, so we quote it consistently — both in the schema and every prepared statement. Second, the `CHECK` constraint on `"column"` lets the database itself reject bad enum values; the TypeScript type narrows the same set in code, but SQL is the final guard. Third, `better-sqlite3` is **synchronous by design**[^3] — there is no `await` here, and a single Node event loop tick can run a full transaction without contention. That property is what makes htmx-style fragment rendering so cheap.

## Step 3 — The Express 5 skeleton with HTML escaping

Create `src/server.ts`:

```ts
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { queries } from "./db.js";
import { renderBoard, renderCard, renderError } from "./views.js";
import { sse } from "./sse.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();

app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, "..", "public")));

app.get("/", (_req, res) => {
  const board = {
    todo: queries.listByColumn.all("todo"),
    doing: queries.listByColumn.all("doing"),
    done: queries.listByColumn.all("done"),
  };
  res.type("html").send(renderBoard(board));
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => {
  console.log(`htmx-kanban listening on http://localhost:${port}`);
});
```

Express 5 handles thrown errors and rejected promises in async handlers natively, so we no longer need the `express-async-errors` shim that v4 required.[^4] The `express.urlencoded` middleware is what lets htmx's default `application/x-www-form-urlencoded` POST body deserialize into `req.body`.

Now create `src/views.ts` for the HTML renderers. We do not pull in a templating engine — for a hypermedia app, raw template literals plus a tiny `escape()` helper is all you need:

```ts
import type { Card, Column } from "./db.js";

const ESCAPE_MAP: Record<string, string> = {
  "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
};
export const esc = (s: string) =>
  s.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c] ?? c);

export const renderCard = (card: Card): string => `
<li class="card" id="card-${card.id}">
  <span>${esc(card.title)}</span>
  <form
    hx-put="/cards/${card.id}/move"
    hx-target="closest .card"
    hx-swap="outerHTML"
    style="display:inline">
    ${(["todo", "doing", "done"] as Column[])
      .filter((c) => c !== card.column)
      .map((c) => `<button name="column" value="${c}">→ ${c}</button>`)
      .join("")}
  </form>
  <button
    hx-delete="/cards/${card.id}"
    hx-target="closest .card"
    hx-swap="outerHTML swap:200ms"
    hx-confirm="Delete this card?">×</button>
</li>`;

const renderColumn = (col: Column, cards: Card[]) => `
<section class="col" id="col-${col}">
  <header><h2>${col}</h2><span class="count">${cards.length}</span></header>
  <ul class="cards">
    ${cards.map(renderCard).join("")}
  </ul>
  <form hx-post="/cards" hx-target="#col-${col} .cards" hx-swap="beforeend">
    <input name="title" required maxlength="200" placeholder="New card…">
    <input type="hidden" name="column" value="${col}">
    <button type="submit">Add</button>
  </form>
</section>`;

export const renderBoard = (board: Record<Column, Card[]>) => `<!doctype html>
<html lang="en"><head>
  <meta charset="utf-8">
  <title>htmx kanban</title>
  <link rel="stylesheet" href="/style.css">
  <script src="https://unpkg.com/htmx.org@2.0.10" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/htmx-ext-sse@2.2.4" crossorigin="anonymous"></script>
  <script src="https://unpkg.com/htmx-ext-response-targets@2.0.4" crossorigin="anonymous"></script>
</head><body hx-ext="sse,response-targets" sse-connect="/events">
  <main class="board">
    ${(["todo", "doing", "done"] as Column[]).map((c) => renderColumn(c, board[c])).join("")}
  </main>
  <div id="toast" sse-swap="toast" hx-swap="innerHTML"></div>
</body></html>`;

export const renderError = (msg: string) =>
  `<p class="error" role="alert">${esc(msg)}</p>`;
```

A few htmx-specific decisions to call out:

- **`hx-confirm`** on the delete button shows the browser's native confirm dialog before the request fires.[^5]
- **`hx-swap="outerHTML swap:200ms"`** delays the DOM swap by 200 ms, which gives a CSS transition time to animate the card out before it disappears.
- The body element carries `hx-ext="sse,response-targets" sse-connect="/events"` — htmx wires up an `EventSource` to that URL and listens for named events.[^6] The `#toast` div has `sse-swap="toast"`, meaning *only* events named `toast` trigger a swap there. We will use other event names for live card updates. (The `response-targets` extension is along for the ride and is what enables the `hx-target-4xx` attribute we use later in Step 6.)

Add `public/style.css` to make the board look like a kanban (any minimal CSS works — keep it short):

```css
* { box-sizing: border-box; }
body { font: 16px/1.4 system-ui; margin: 0; padding: 1rem; background: #0f172a; color: #e2e8f0; }
.board { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; }
.col { background: #1e293b; border-radius: 8px; padding: 1rem; }
.col header { display: flex; justify-content: space-between; align-items: baseline; }
.cards { list-style: none; padding: 0; margin: 0 0 1rem; }
.card { background: #334155; border-radius: 6px; padding: 0.5rem; margin-bottom: 0.5rem;
        display: flex; gap: 0.5rem; align-items: center; transition: opacity 200ms; }
.card.htmx-swapping { opacity: 0; }
.card span { flex: 1; }
button { background: #475569; color: inherit; border: 0; padding: 0.25rem 0.5rem;
         border-radius: 4px; cursor: pointer; }
button:hover { background: #64748b; }
input[name="title"] { padding: 0.5rem; border-radius: 4px; border: 0; width: 100%; margin-bottom: 0.5rem; }
.error { color: #fca5a5; margin: 0.5rem 0; }
#toast:not(:empty) { position: fixed; bottom: 1rem; right: 1rem;
        background: #1e3a8a; padding: 0.75rem 1rem; border-radius: 6px; }
```

That `.htmx-swapping` rule is the matching half of the `swap:200ms` setting — htmx adds the class while the swap is in flight, your CSS animates opacity, and the browser does the rest.

## Step 4 — Wire up Create, Move, Delete with HTML fragments

Now add the mutation routes. Each one returns an HTML fragment, never JSON.

```ts
// src/server.ts (continued)

app.post("/cards", (req, res) => {
  const title = String(req.body.title ?? "").trim();
  const column = String(req.body.column ?? "") as "todo" | "doing" | "done";
  if (!title || title.length > 200) {
    return res.status(400).type("html").send(renderError("Title is required (1–200 chars)."));
  }
  if (!["todo", "doing", "done"].includes(column)) {
    return res.status(400).type("html").send(renderError("Invalid column."));
  }
  const nextSort = (queries.maxSort.get(column)?.m ?? -1) + 1;
  const result = queries.insert.run(title, column, nextSort);
  const card = queries.getById.get(Number(result.lastInsertRowid));
  if (!card) return res.status(500).type("html").send(renderError("Insert failed."));
  sse.broadcast("card-added", renderCard(card) + countOob(column));
  res.type("html").send(renderCard(card));
});

app.put("/cards/:id/move", (req, res) => {
  const id = Number(req.params.id);
  const column = String(req.body.column ?? "") as "todo" | "doing" | "done";
  const existing = queries.getById.get(id);
  if (!existing) return res.status(404).type("html").send(renderError("Card not found."));
  if (!["todo", "doing", "done"].includes(column)) {
    return res.status(400).type("html").send(renderError("Invalid column."));
  }
  queries.move.run(column, id);
  const updated = queries.getById.get(id);
  if (!updated) return res.status(500).type("html").send(renderError("Move failed."));

  // The mover's own response moves the card by replacing it with a fragment that
  // re-attaches it under the new column via hx-swap-oob.
  const fragment = oobInsert(updated) + countOob(existing.column) + countOob(column);
  sse.broadcast("card-moved", fragment);
  res.type("html").send(`<li id="card-${id}" hx-swap-oob="delete"></li>${fragment}`);
});

app.delete("/cards/:id", (req, res) => {
  const id = Number(req.params.id);
  const card = queries.getById.get(id);
  if (!card) return res.status(404).send("");
  queries.remove.run(id);
  sse.broadcast("card-removed", `<li id="card-${id}" hx-swap-oob="delete"></li>${countOob(card.column)}`);
  res.type("html").send("");
});

function oobInsert(card: Card): string {
  // Returns a card fragment plus an OOB instruction to append it to the right column's <ul>.
  return `<ul id="col-${card.column}-cards-oob" hx-swap-oob="beforeend:#col-${card.column} .cards">${renderCard(card)}</ul>`;
}

function countOob(column: "todo" | "doing" | "done"): string {
  const count = queries.listByColumn.all(column).length;
  return `<span class="count" id="col-${column}-count" hx-swap-oob="outerHTML:#col-${column} .count">${count}</span>`;
}
```

This is the most subtle part of the tutorial, so let me walk through it slowly.

When the user clicks "→ doing" on a card sitting in `todo`, the form fires `PUT /cards/:id/move` with `hx-target="closest .card" hx-swap="outerHTML"`. By default htmx replaces the clicked card with whatever HTML the server returns. But we don't want to *replace* it in place — we want to *delete it from the source column* and *append it to the destination column*, plus update both column counts.

That is exactly the job of `hx-swap-oob`.[^7] The response is a soup of small fragments, each with an `id` attribute and an `hx-swap-oob` directive. htmx scans the response and applies each one independently:

- `<li id="card-42" hx-swap-oob="delete">` — finds the existing element on the page with id `card-42` and applies the `delete` swap strategy. No selector needed: by default an element with `hx-swap-oob` is matched against the live DOM by `id`.
- `<ul ... hx-swap-oob="beforeend:#col-doing .cards">` — appends the wrapped card to the `.cards` list inside `#col-doing`. This is the CSS-selector form: `<swap-strategy>:<selector>`.
- Two `<span ... hx-swap-oob="outerHTML:#col-todo .count">` and `outerHTML:#col-doing .count` — update both column counts.

The two forms are interchangeable but optimized for different cases. The id-matching form (`hx-swap-oob="delete"` on an element with an `id`) is the simplest. The selector form (`hx-swap-oob="beforeend:#col-doing .cards"`) targets by CSS selector and lets you pair any swap strategy with any target.[^7] One round trip, four DOM mutations, zero client-side state.

## Step 5 — Live multi-user updates with Server-Sent Events

If two people have the board open and one of them moves a card, the other browser should see it move too — without polling. That is the canonical SSE use case: server-to-client, uni-directional, over the same HTTP connection your app already uses, no upgrade dance.[^8]

Create `src/sse.ts`:

```ts
import type { Response } from "express";

class SseHub {
  private clients = new Set<Response>();

  add(res: Response) {
    res.set({
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      "Connection": "keep-alive",
      "X-Accel-Buffering": "no",
    });
    res.flushHeaders();
    res.write(`retry: 5000\n\n`);
    this.clients.add(res);
    res.on("close", () => this.clients.delete(res));
  }

  broadcast(event: string, html: string) {
    const data = html.replace(/\n/g, "\ndata: ");
    const payload = `event: ${event}\ndata: ${data}\n\n`;
    for (const c of this.clients) c.write(payload);
  }

  count() { return this.clients.size; }
}

export const sse = new SseHub();
```

Register the endpoint in `src/server.ts` before `app.listen`:

```ts
app.get("/events", (req, res) => {
  sse.add(res);
  // Heartbeat every 25s so reverse proxies don't time the connection out.
  const heartbeat = setInterval(() => res.write(`: heartbeat\n\n`), 25_000);
  req.on("close", () => clearInterval(heartbeat));
});
```

Three SSE protocol details that bite people:

1. **Every line of the `data:` payload must be prefixed with `data: `**. If your HTML contains newlines, you must replace each `\n` with `\ndata: ` or the browser silently drops the message.[^8] That is what the `replace(/\n/g, "\ndata: ")` line does.
2. **The double `\n\n` is the message terminator.** Forget the second newline and the browser will buffer your event forever, waiting for the boundary.
3. **`retry: 5000`** tells the browser to wait 5 s before reconnecting if the connection drops. Useful behind flaky proxies.

Now the htmx side. The `<body>` already declares `hx-ext="sse,response-targets" sse-connect="/events"`, so the EventSource is open. To swap on a named event, wrap the affected region in a container with `sse-swap="<event-name>"`. But we have something subtler going on — the broadcasts contain *only `hx-swap-oob` fragments*. htmx's SSE extension treats incoming events the same way it treats AJAX responses, which means OOB swaps in the payload are processed automatically against the live DOM.[^9]

Add a single hidden listener at the bottom of the body, just before `</body>`, by editing `renderBoard`:

```ts
// inside renderBoard, just above </body>:
<div id="sse-sink" sse-swap="card-added,card-moved,card-removed" hx-swap="none"></div>
```

`hx-swap="none"` tells htmx not to put the response *into* the sink — but it still parses the response and applies the OOB instructions. That is exactly the trick that makes this whole thing work with a single ten-line endpoint.

Open the app in two browser windows side by side. Add a card in window A. It appears in window B within milliseconds, with no refresh, no polling, no WebSocket handshake.

## Step 6 — Validation and htmx-aware error fragments

The validation in Step 4 is bare-bones. Real apps need a place where errors become visible to the user. The htmx idiom is to return the error fragment with a 4xx status and target a sibling error region. Update the new-card form in `renderColumn`:

```ts
<form
  hx-post="/cards"
  hx-target="#col-${col} .cards"
  hx-swap="beforeend"
  hx-on::after-request="if(event.detail.successful) this.reset()"
  hx-target-4xx="#col-${col} .form-error">
  <input name="title" required maxlength="200" placeholder="New card…">
  <input type="hidden" name="column" value="${col}">
  <button type="submit">Add</button>
  <div class="form-error" id="col-${col}-err"></div>
</form>
```

Two attributes earn their keep here. `hx-on::after-request` is htmx 2.x's inline event handler — the double-colon form is shorthand for `hx-on:htmx:after-request`. The handler runs on every request completion, success or failure, so we gate `this.reset()` on `event.detail.successful` — that flag is `true` for 2xx responses only.[^10] `hx-target-4xx` (status-code-specific target) sends 400/422 responses into the error `<div>` instead of the cards list, so the user sees the error message inline and the cards list isn't polluted with `<p class="error">` markup. That attribute lives in the [`response-targets` extension](https://htmx.org/extensions/response-targets/) — already wired into the page via the third `<script>` tag and the `hx-ext="sse,response-targets"` on `<body>`.[^11] One attribute, no client-side `try/catch`.

## Step 7 — Verify everything with curl

Before opening the browser, prove the wire format is exactly what you expect. Run the dev server (`npm run dev`) in one terminal and the following in another:

```bash
# 1. Create a card. Expect a single <li class="card" id="card-1">…
curl -is -X POST http://localhost:3000/cards \
  -d 'title=Write tutorial&column=todo' | head

# 2. Move it to "doing". Expect a delete-OOB plus an insert-OOB plus two count-OOBs.
curl -is -X PUT http://localhost:3000/cards/1/move -d 'column=doing'

# 3. Watch the SSE stream while you mutate from another terminal.
curl -N http://localhost:3000/events
```

The third command should sit silently until you do something in another terminal — at which point you'll see lines like:

```
event: card-moved
data: <li class="card" hx-swap-oob="delete:#card-1"></li>...
```

If you see your fragments come through here, the browser will too. If the `data:` lines are missing or the events look mangled, your newline handling in `SseHub.broadcast` is the first place to look.

## Troubleshooting

These are the failure modes I hit while building this exact stack — every one comes with a real fix.

**htmx fires the request but no swap happens.** The selector in `hx-target` doesn't match anything on the page. Open DevTools, run `document.querySelector("#col-todo .cards")` in the console, and you'll usually find the typo immediately.

**SSE connects but no events ever arrive.** Two causes: the `data:` newline rule above, or a reverse proxy buffering responses. Nginx with default settings buffers SSE; the `X-Accel-Buffering: no` header in `SseHub.add` disables it, but only for nginx. On Cloudflare, set `Cache-Control: no-cache` (already done) and confirm your route isn't behind a "cache everything" rule.[^12]

**`hx-swap-oob` fragments are silently ignored.** This usually means the response includes them inside another element htmx considered the primary swap target. The simplest fix: make sure your response is *only* OOB fragments (no wrapper element), or include a deliberate primary fragment first followed by OOB siblings.

**`req.body` is `undefined`.** You forgot `app.use(express.urlencoded({ extended: false }))`. htmx posts as `application/x-www-form-urlencoded` by default; without the parser middleware, `req.body` is `undefined`.

**TypeScript complains about `result.lastInsertRowid`.** `better-sqlite3` returns `lastInsertRowid` as `number | bigint` — for any realistic table you can safely cast with `Number()`, but if your IDs ever exceed 2^53 you must use `bigint` end to end.

## Where to take this next

The codebase as written is roughly 200 lines of TypeScript across four files and is a complete, runnable demo. Three obvious extensions:

- **Persisted column ordering.** The current code only inserts at the end of a column. To support reordering inside a column, capture the insertion index in the move form (`<input type="hidden" name="position" value="...">`) and recompute `sort_order` for the destination column inside a `db.transaction(...)` block.
- **Multi-board support.** Add a `boards` table with a `slug`, key every card off `board_id`, and route everything through `/b/:slug`. The SSE hub can fan out by `board_id` so users only see broadcasts for their own board.
- **Auth.** Express 5 plus `express-session` and a single `requireUser` middleware is enough — keep the htmx fragments untouched, but wrap the routes.

If you want to compare alternatives to SSE for real-time HTML over the wire, our walkthrough of [Postgres LISTEN/NOTIFY for real-time presence](https://nerdleveltech.com/postgres-listen-notify-realtime-presence-tutorial) shows the same pattern with Postgres triggers as the pub/sub spine. For a deeper look at production-grade Postgres pooling that fits this kind of synchronous-DB workload, see our [pgbouncer and supavisor pooling tutorial](https://nerdleveltech.com/production-postgres-pooling-pgbouncer-supabase-supavisor-tutorial). And if you decide htmx is not the right call after all, our [Next.js Pages-to-App-Router migration tutorial](https://nerdleveltech.com/nextjs-pages-router-to-app-router-migration-tutorial) covers the modern Next.js stack end to end.

The htmx 4.0 line has moved from alpha into beta, with `4.0.0-beta5` published on npm on June 26, 2026 and the project targeting a stable 4.0 release later in 2026.[^13] The 2.x line remains the production-recommended release in the meantime. Pin to 2.0.10, ship it, and revisit when 4.0 has a stable release tag.

[^1]: ["Node.js Releases" — nodejs.org](https://nodejs.org/en/about/previous-releases). Node.js 24 entered Active LTS on October 28, 2025 (after six months as Current following the May 6, 2025 v24.0.0 release); the 24.15.0 patch shipped in April 2026; LTS support continues through April 30, 2028. Node.js 26.0.0 became the Current line on May 5, 2026 and will not enter LTS until October 2026.

[^2]: ["tsx — TypeScript Execute" — tsx.is](https://tsx.is/). tsx is a thin esbuild wrapper for running TypeScript and ESM files in Node directly; the latest release on npm is 4.21.0.

[^3]: ["better-sqlite3" — npm](https://www.npmjs.com/package/better-sqlite3). The README explicitly documents the synchronous-by-design API; version 12.9.0 supports Node 20.x through 25.x.

[^4]: ["Express@5.1.0: Now the Default on npm" — expressjs.com](https://expressjs.com/2025/03/31/v5-1-latest-release.html). Express 5 became the default `latest` tag on npm in March 2025; it includes native handling for thrown errors and rejected promises in async route handlers. The latest patch on the 5.x line at the time of writing is 5.2.1.

[^5]: ["hx-confirm" — htmx.org](https://htmx.org/attributes/hx-confirm/). Triggers a `window.confirm()` dialog before the request is issued.

[^6]: ["The htmx Server Sent Event (SSE) Extension" — htmx.org](https://htmx.org/extensions/sse/). The 2.x extension uses `hx-ext="sse"` to install and `sse-connect` plus `sse-swap` to consume events.

[^7]: ["hx-swap-oob Attribute" — htmx.org](https://htmx.org/attributes/hx-swap-oob/). Out-of-band swap; supports `true`, swap-strategy values, and selector forms like `outerHTML:#some-selector` or `beforeend:#some-selector`.

[^8]: ["Server-sent events" — MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events). Documents the `text/event-stream` content type, line-prefixed `data:` framing, the double-newline message terminator, and the `retry:` field.

[^9]: ["The htmx Server Sent Event (SSE) Extension" — htmx.org](https://htmx.org/extensions/sse/). The extension hands SSE payloads to the standard htmx swap pipeline, which means out-of-band swap directives in the payload are honored.

[^10]: ["hx-on" — htmx.org](https://htmx.org/attributes/hx-on/). The `hx-on::after-request` shorthand runs an inline expression when htmx finishes the request lifecycle.

[^11]: ["The htmx Response Targets Extension" — htmx.org](https://htmx.org/extensions/response-targets/). `hx-target-[CODE]` and `hx-target-error` are provided by the `response-targets` extension; the `4xx` variant matches any 400-series status. The `htmx-ext-response-targets` package is at version 2.0.4 on npm.

[^12]: ["NGINX Server-Sent Events" — Nginx documentation, plus Cloudflare community thread on SSE buffering](https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_buffering). The `X-Accel-Buffering: no` response header is recognized by nginx to disable response buffering on a per-response basis.

[^13]: ["htmx 4.0 roadmap" — htmx GitHub Discussion #2198](https://github.com/bigskysoftware/htmx/discussions/2198) and the [htmx releases page](https://github.com/bigskysoftware/htmx/releases). 4.0 has progressed from alpha into beta — `4.0.0-beta5` was published on npm on June 26, 2026 — with the project targeting a stable 4.0 release later in 2026. The 2.x line is the production-recommended release; the latest 2.x is 2.0.10 on npm and on the official CDN snippets at htmx.org/extensions/sse/.
