htmx Internals: Reimplementing the Library From Scratch
Technical Deep-Dive · August 2026

htmx From Scratch:
What the Library Does Under the Hood

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.

Oleg Maximov August 5, 2026 14 min read

Introduction

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.

The Core Idea: Scan, Send, Swap

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.

Method, Trigger, Target, Swap

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.

Swap strategies

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 modeWhat it does
innerHTMLReplace the target's children (default)
outerHTMLReplace the target element itself
beforebegin / afterendInsert before / after the target
afterbegin / beforeendInsert as first / last child
deleteRemove the target element
noneDo nothing with the body (use headers only)

The generic sender

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());
};

Scanning the DOM

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.

Better Triggers

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 };
  });
}

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.

Better Targets

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.

Events and the HX-Trigger Protocol

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:

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.

Plugins Without Touching the Core

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:

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.

Out-of-Band Swaps: Updating Several Parts at Once

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.

What the Clone Doesn't Do (and Why the Real htmx Exists)

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.

The mental model in one box

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 vs Frameworks: Where It Fits

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.

FAQ

What is htmx and why is it called HTML-first?
htmx is a small JavaScript library that adds AJAX, transitions, and interactivity directly to HTML via hx-* attributes. It is HTML-first because the behavior lives in the markup: hx-get, hx-post, hx-target, and hx-swap describe the request, the target, and how the response replaces the page. Under the hood it's a scan/send/swap engine — find elements with hx-* attributes, bind triggers, send fetch() requests, and swap the returned HTML into the DOM.
How does htmx swap HTML into the page?
htmx parses the response text into DOM nodes using a template element and applies a swap strategy. The default is innerHTML (replace the target's children), with outerHTML, beforebegin, afterbegin, beforeend, afterend, delete, and none also supported. With hx-swap-oob, an element in the response marked hx-swap-oob="true" is pulled out and swapped into a matching element elsewhere on the page — so one response can update several parts of the UI.
What do the HX-Trigger, HX-Redirect and HX-Reswap headers do?
These are response headers the server sends to control the client after an AJAX request. HX-Trigger tells htmx to dispatch a custom event on the page (a plain name or a JSON object with event names and detail values). HX-Redirect performs a client-side redirect, HX-Refresh reloads the page, and HX-Retarget/HX-Reswap override the target and swap mode chosen by the request. Together they turn a plain HTML response into a full client-server protocol.
Do I need a JavaScript framework with htmx?
No. htmx is designed for the opposite trade-off: the server renders HTML templates and htmx handles the AJAX and DOM updates, keeping the application logic on the backend and removing the build step, client-side routing, and state management. For a server-rendered application with moderate interactivity, htmx can replace React or Vue entirely. If you're weighing this decision for a business site, see my React vs Plain HTML decision guide.
Is htmx worth learning in 2026?
Yes — for backend developers and teams building server-rendered applications, htmx is one of the most productive additions to the stack. The library is about 14 KB minified, has no dependencies, works with Django, Rails, Laravel, ASP.NET, Go, and Node.js, and removes a whole category of client-side complexity. The ideas behind it — declarative attributes, hypermedia, progressive enhancement — also explain the broader HTML-first shift in web development.
Where can I find the code from this guide?
The reimplementation described in this article is Serge Zaitsev's project "Let's make the worst htmx ever" (July 2026, featured in JavaScript Weekly issue 797). The full source is on GitHub at github.com/zserge/x and the write-up is on zserge.com/posts/worst-htmx-ever. The real htmx source lives at github.com/bigskysoftware/htmx.

Want to Build an HTML-First Application?

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.

Contact

Let's build something fast

Have a project in mind? I'll help you choose the right architecture — hypermedia or framework — and build it right. Free initial consultation.