htmx promises dynamic web applications without writing JavaScript. This guide rebuilds a minimal htmx clone step by step to show exactly how the magic works — scan, send, swap.
htmx is the frontend library for backend developers who would rather not write
JavaScript at all. You put a few hx-* attributes on your HTML, and
suddenly your server-rendered pages get AJAX requests, partial updates, and
smooth transitions — all without a build step, a virtual DOM, or a state
management library.
In July 2026, Serge Zaitsev published a delightful article called "Let's make the worst htmx ever!" — the latest in his series of tiny framework clones. JavaScript Weekly issue 797 (August 4, 2026) featured it, and it's the perfect lens for understanding what htmx actually does: the whole library, in its essence, is three functions: scan, send, and swap.
In this deep-dive I reimplement the same clone with you, section by section: how
attribute-driven triggers fire AJAX requests, how responses are parsed and swapped
into the DOM, how target and swap resolution works, how the HX-Trigger
response header turns a server response into client events, and how the design makes
plugins possible without touching the core. If you've ever wondered "how does htmx
work?" — this is the answer, in ~100 lines of readable JavaScript.
If you're still deciding whether your website needs a JavaScript framework at all, start with my React vs Plain HTML decision guide — htmx is exactly the middle path that guide points to.
Look at a typical htmx usage example. It says, declaratively: when the button is
clicked, send a POST request to /clicked, and replace the element with
id #parent-div with whatever HTML the server returns.
<button hx-post="/clicked"
hx-trigger="click"
hx-target="#parent-div"
hx-swap="outerHTML">
Click Me!
</button>
Underneath, htmx wires together three concerns: events (the trigger),
AJAX requests (the fetch), and DOM updates (the swap).
That's the entire mental model. The htmx source code is organized around a loop that
finds elements with hx-* attributes (scan), listens for their triggers and
issues requests (send), and replaces parts of the page (swap).
The minimal version of this idea is surprisingly short. Here is a button that fetches a URL on click and replaces itself with the response:
<button x-get="/click">Click me</button>
document.querySelectorAll('[x-get]').forEach(el => {
el.addEventListener('click', async (e) => {
e.preventDefault();
const url = el.getAttribute('x-get');
const response = await fetch(url);
const data = await response.text();
el.outerHTML = data;
});
});
If the server responds with "Button clicked", the button disappears and the text takes its place. That's htmx in ten lines. The rest of this article is about making those ten lines generic, robust, and extensible.
The ten-line version hard-codes the trigger (click), the target (the
element itself), and the swap mode (outerHTML). A real library must let
the markup specify all four: method, trigger,
target, and swap mode.
The response arrives as a text string. The first trick is to parse it into actual DOM
nodes using a template element — a cheap, side-effect-free HTML parser.
Then each swap mode is just a DOM operation:
const attr = (el, name) => el.closest(`[${name}]`)?.getAttribute(name);
const SWAP = {
outerHTML: (t, f) => t.replaceWith(f),
beforebegin: (t, f) => t.before(f),
afterbegin: (t, f) => t.prepend(f),
beforeend: (t, f) => t.append(f),
afterend: (t, f) => t.after(f),
delete: t => t.remove(),
none: () => {},
};
const swap = (mode, target, html) => {
const tpl = document.createElement('template');
tpl.innerHTML = html;
(SWAP[mode] || ((t, f) => t.replaceChildren(f)))(target, tpl.content);
};
| Swap mode | What it does |
|---|---|
innerHTML | Replace the target's children (default) |
outerHTML | Replace the target element itself |
beforebegin / afterend | Insert before / after the target |
afterbegin / beforeend | Insert as first / last child |
delete | Remove the target element |
none | Do nothing with the body (use headers only) |
With swap in place, a generic send() function reads the target and mode
from attributes, follows the htmx convention of sending a custom request header
(HX-Request: true) so the server knows it's an AJAX call, and serializes
forms if the element is a form:
const send = async (el, method, url) => {
const sel = attr(el, 'x-target');
const target = sel ? document.querySelector(sel) : el;
const mode = attr(el, 'x-swap') || 'innerHTML';
const opts = { method: method.toUpperCase(), headers: { 'X-Request': 'true' } };
if (el.matches('form')) opts.body = new URLSearchParams(new FormData(el));
const res = await fetch(url, opts);
swap(mode, target, await res.text());
};
Now we need to find every element that declares behavior and bind its trigger. The
default trigger follows HTML conventions: forms fire on submit, inputs on
change, everything else on click. A $hx flag
prevents double-binding when the same element is scanned twice:
const METHODS = ['get', 'post', 'put', 'patch', 'delete'];
const defaultTrigger = el =>
el.matches('form') ? 'submit' : el.matches('input,select,textarea') ? 'change' : 'click';
const scan = (root = document.body) =>
METHODS.forEach(m =>
root.querySelectorAll(`[x-${m}]`).forEach(el => {
if (el.$hx) return;
el.$hx = true;
const evt = attr(el, 'x-trigger') || defaultTrigger(el);
el.addEventListener(evt, e => {
e.preventDefault();
send(el, m, el.getAttribute(`x-${m}`));
});
})
);
scan();
Since every swap can bring new interactive elements into the DOM, a
MutationObserver re-scans only the newly added nodes — much cheaper than
re-scanning the whole document after each request:
new MutationObserver(ms => {
for (const m of ms) m.addedNodes.forEach(n => { if (n.nodeType === 1) scan(n); });
}).observe(document.body, { childList: true, subtree: true });
That's a working clone in about forty lines: all HTTP methods, custom targets, and swap strategies.
Real htmx lets you write trigger expressions like
hx-trigger="load, click one, change changed delay:500". The clone can do
the same with a small parser: split on commas, then split each trigger on spaces into
an event name plus key:value options:
const parseTriggers = (s) => {
const triggers = s.split(',').map(s => s.trim());
return triggers.map(trigger => {
const [event, ...rest] = trigger.split(' ');
const options = {};
rest.forEach(opt => {
const [key, value = true] = opt.split(':');
options[key] = value;
});
return { event, options };
});
}
load — fire the request when the element appears (a setTimeout of 0).changed — cache the previous value and skip the send if it hasn't changed.once — remove the listener after the first trigger.delay:500 — debounce the trigger by the given milliseconds.
The real htmx goes much further — viewport-based triggers (revealed,
intersect), event throttling, queueing, polling via
hx-trigger="every 5s". But the pattern is the same: a declarative
mini-language parsed into event listeners.
A plain document.querySelector can only find elements by CSS selector.
htmx extends the target syntax with relative keywords, and the clone mirrors it with a
resolve() function:
const resolve = (el, sel) => {
if (!sel) return el;
if (sel === 'this') return el;
if (sel === 'next') return el.nextElementSibling;
if (sel === 'previous') return el.previousElementSibling;
if (sel === 'document') return document;
if (sel === 'body') return document.body;
if (sel === 'window') return window;
if (sel.startsWith('closest ')) return el.closest(sel.slice(8));
if (sel.startsWith('find ')) return el.querySelector(sel.slice(5));
return document.querySelector(sel);
};
Now you can write x-target="closest .container" or
x-target="next" — and htmx's own source does roughly the same thing for
hx-target. Relative targeting is what makes components portable: a widget
can always update its own container regardless of where it's placed in the page.
The most interesting part of htmx is not the swapping — it's the event protocol that
lets the server drive the client. The clone emits four custom events on the triggering
element: x:beforeSend, x:afterSend,
x:beforeSwap, and x:afterSwap. Each carries the full request
context, and beforeSend / beforeSwap can be cancelled with
event.preventDefault().
Then the server gets a voice through HX-* response headers:
HX-Trigger — dispatch a custom event on the page, either a plain event name or a JSON object mapping event names to detail values.HX-Redirect — navigate the browser to the given URL.HX-Refresh — reload the page.HX-Retarget / HX-Reswap — override the target and swap mode chosen by the request.const send = async (el, method, url) => {
let target = resolve(el, attr(el, "x-target"));
let mode = attr(el, "x-swap") || "innerHTML";
const opts = {
method: method.toUpperCase(),
headers: { "HX-Request": "true" },
};
if (el.matches("form")) opts.body = new URLSearchParams(new FormData(el));
if (!fire(el, "x:beforeSend", { el, url, target, mode, opts }, true))
return;
const response = await fetch(url, opts);
const hdrTrigger = response.headers.get("HX-Trigger");
if (hdrTrigger) {
try {
const data = JSON.parse(hdrTrigger);
Object.entries(data).forEach(([ev, d]) => fire(document.body, ev, d));
} catch {
fire(document.body, hdrTrigger);
}
}
if (response.headers.get("HX-Redirect")) {
window.location.href = response.headers.get("HX-Redirect");
return;
}
if (response.headers.get("HX-Refresh") === "true") {
window.location.reload();
return;
}
if (response.headers.get("HX-Retarget"))
target = resolve(el, response.headers.get("HX-Retarget"));
if (response.headers.get("HX-Reswap"))
mode = response.headers.get("HX-Reswap");
const html = await response.text();
fire(el, "x:afterSend", { el, url, opts, target, mode, response, html });
const detail = { el, url, target, mode, html, response };
if (!fire(el, "x:beforeSwap", detail, true)) return;
swap(detail.mode, detail.target, detail.html);
fire(el, "x:afterSwap", detail);
scan(detail.target); // rebind anything the swap just brought in
};
This is the heart of the hypermedia model: the server doesn't send JSON data and let the client decide what to do — it sends HTML and instructions, and the client executes them. A page can refresh a dashboard, show a toast, and update a counter from a single response using only headers.
Because every step of the request lifecycle is an interceptable event, the clone's "essence" stays small — scan + send + swap — and everything else becomes a plugin. Zaitsev lists a dozen that require no changes to the core:
click listener).x-confirm — pop a native confirm() before sending, cancel via preventDefault() on x:beforeSend.x-indicator — show a spinner while a request is in flight (toggle a class in x:beforeSend / x:afterSwap).x-disable — disable the element during the request to prevent double submission.x-headers / x-vals — inject extra headers or values into the request.x-select — swap only a fragment of the response (keep backend templates simpler).x-sync — keep at most one request in flight per target.x-push-url — update browser history via pushState after the swap.x-sse / x-ws — stream responses over Server-Sent Events or WebSockets.Boosting is a great example — it doesn't even need to know about the core loop:
document.addEventListener('click', e => {
const boosted = e.target.closest('[x-boost]');
if (!boosted) return;
const link = e.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href || href.startsWith('#') || link.getAttribute('target')) return;
e.preventDefault();
window.x.send(boosted, 'get', href);
});
That's the architectural lesson of htmx: a tiny, well-defined core with an
event-based extension point scales much better than a monolithic feature list.
In real htmx, hx-ext and the same lifecycle events power an official
extension ecosystem.
One real htmx feature the minimal clone deliberately skips is
out-of-band swapping (hx-swap-oob). It solves a common
problem: a single request should update multiple regions of the page — the list, the
counter, and a status message.
The trick is that the response is treated as a mini-document. Any element in the
response with hx-swap-oob="true" is pulled out and swapped into the
matching element on the page (matched by id); everything else is swapped into the
target as usual. A comment form is a textbook case:
<form hx-post="/comments" hx-target="#comments-list" hx-swap="beforeend">
<input name="text" required>
<button type="submit">Add comment</button>
</form>
<div id="comments-list">
<!-- existing comments -->
</div>
The server responds with the new comment plus a counter element marked
hx-swap-oob="true":
<div id="comment-count" hx-swap-oob="true">42 comments</div>
<div class="comment">Great article, thanks!</div>
The counter flies to #comment-count elsewhere on the page while the
comment is appended to the list. One request, one response, two UI updates — no
client-side state, no re-render logic.
The clone is honest about its limits. Real htmx adds error handling for failed
fetch() calls and non-2XX responses, promise-based cancellation for
asynchronous confirmation dialogs, view-transition coordination, history integration
with hx-push-url, form validation hooks, and the full trigger grammar. All
of these are where production edge cases live.
The full clone source is on GitHub at github.com/zserge/x, and the write-up is on zserge.com. The real library — about 14 KB minified, zero dependencies — is at htmx.org.
htmx = scan (find hx-* attributes) +
send (fire fetch() on triggers, read
HX-* headers) + swap (parse the HTML response and
update the DOM). Everything else — triggers, targets, plugins, out-of-band swaps —
is a refinement of these three steps.
htmx is not a competitor to React, Vue, or Angular in the SPA sense — it's an alternative architecture. For a server-rendered application with moderate interactivity (forms, filters, pagination, dashboards), htmx keeps the logic on the backend, removes the build step, and produces smaller pages. For a client-heavy application with offline support, optimistic updates, and complex local state, a framework is still the right tool. My React vs Vue vs Angular comparison covers the framework side in depth.
The htmx philosophy also fits a broader industry trend toward HTML-first development: CSS now handles more and more presentation logic that used to be JavaScript's job. For a deep look at that shift, see my guide to CSS replacing JavaScript.
Understanding what htmx does under the hood makes you a better judge of when to use it — and when a different architecture fits better. As a full-stack developer, I build server-rendered applications and SPAs alike, choosing the approach that minimizes complexity for each project.
If you're planning a web application and want an experienced perspective on whether an HTML-first approach or a JavaScript framework fits your product, reach out — I provide free initial consultations, no pressure, no sales pitch.
Have a project in mind? I'll help you choose the right architecture — hypermedia or framework — and build it right. Free initial consultation.