htmx Tutorial: TypeScript Express Live Kanban (2026)

May 9, 2026

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

ToolVersionReason
Node.js24.15.0 (Active LTS, April 2026)1Express 5 needs Node 18+; LTS gets security patches through April 2028
npm10.x (bundled with Node 24)Package install
SQLitebundled in better-sqlite3 12.9.0No separate install needed
A modern browserChrome 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

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:

{
  "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:

{
  "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:

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 design3 — 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:

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:

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):

* { 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.

// 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:

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:

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:

// 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:

<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 — 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:

# 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 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. And if you decide htmx is not the right call after all, our Next.js Pages-to-App-Router migration tutorial covers the modern Next.js stack end to end.

The htmx 4.0 line is in active alpha development as of May 2026, with the project targeting a beta release in mid-2026 and a stable 4.0 cut in early 2027.13 The 2.x line remains the production-recommended release through that window. Pin to 2.0.10, ship it, and revisit when 4.0 has a stable release tag.

Footnotes

  1. "Node.js Releases" — nodejs.org. 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. 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. 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. 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. Triggers a window.confirm() dialog before the request is issued.

  6. "The htmx Server Sent Event (SSE) Extension" — htmx.org. 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. Out-of-band swap; supports true, swap-strategy values, and selector forms like outerHTML:#some-selector or beforeend:#some-selector. 2

  8. "Server-sent events" — MDN Web Docs. Documents the text/event-stream content type, line-prefixed data: framing, the double-newline message terminator, and the retry: field. 2

  9. "The htmx Server Sent Event (SSE) Extension" — htmx.org. 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. The hx-on::after-request shorthand runs an inline expression when htmx finishes the request lifecycle.

  11. "The htmx Response Targets Extension" — htmx.org. 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. 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 and the htmx releases page. 4.0 is in alpha — 4.0.0-alpha8 shipped during early 2026 — with the project targeting a beta in mid-2026 and a stable 4.0 in early 2027. 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/.


FREE WEEKLY NEWSLETTER

Stay on the Nerd Track

One email per week — courses, deep dives, tools, and AI experiments.

No spam. Unsubscribe anytime.