Building a Single-Page App with htmx


People talk about htmx as though it’s saving the web from single-page apps. React has mired developers in complexity (so the story goes) and htmx is offering a desperately-needed lifeline.

htmx creator Carson Gross wryly explains the dynamic like this htmx.org / CEO of Delete Your Account (same thing) (@htmx_org) on X @iamjohndorn @flaviocopes no, this is a Hegelian dialectic: - thesis: traditional MPAs - antithesis: SPAs - synthesis (higher form): hypermedia-driven applications w/ islands of intereactivity twitter.com/htmx_org/status/1736849183112360293 :

no, this is a Hegelian dialectic:

  • thesis: traditional MPAs
  • antithesis: SPAs
  • synthesis (higher form): hypermedia-driven applications w/ islands of intereactivity

Well, I guess I missed the memo, because I used htmx to build a single-page app htmx spa jakelazaroff.github.io/htmx-spa/ .

It’s a simple proof of concept todo list. Once the page is loaded, there is no additional communication with a server. Everything happens locally on the client.

How does that work, given that htmx is focused on managing hypermedia exchanges over the network?

With one simple trick:1 the “server-side” code runs in a service worker Service Worker API - Web APIs | MDN Service workers essentially act as proxy servers that sit between web applications, the browser, and the network (when available). They are intended, among other things, to enable the creation of effective offline experiences, intercept network requests, and take appropriate action based on whether the network is available, and update assets residing on the server. They will also allow access to push notifications and background sync APIs. developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API .

Briefly, a service worker acts as a proxy between a webpage and the wider Internet. It intercepts network requests and allows you to manipulate them. You can alter requests, cache responses to be served offline or even create new responses out of whole cloth without ever sending the request beyond the browser.

That last capability is what powers this single-page app. When htmx makes a network request, the service worker intercepts it. The service worker then runs the business logic and generates new HTML, which htmx then swaps into the DOM.

There are a couple of advantages over a traditional single-page app built with something like React, too. Service workers must use IndexedDB IndexedDB API - Web APIs | MDN IndexedDB is a low-level API for client-side storage of significant amounts of structured data, including files/blobs. This API uses indexes to enable high-performance searches of this data. While Web Storage is useful for storing smaller amounts of data, it is less useful for storing larger amounts of structured data. IndexedDB provides a solution. This is the main landing page for MDN's IndexedDB coverage — here we provide links to the full API reference and usage guides, browser support details, and some explanation of key concepts. developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API for storage, which is stateful between page loads. If you close the page and then come back, the app retains your data — this happens “for free”, a pit of success Falling Into The Pit of Success Eric Lippert notes the perils of programming in C++: I often think of C++ as my own personal Pit of Despair Programming Language. Unmanaged C++ makes it so easy to fall into traps. Think buffer overruns, memory leaks, double frees, mismatch between allocator and deallocator, using freed memory, umpteen dozen blog.codinghorror.com/falling-into-the-pit-of-success/ consequence of choosing this architecture. The app also works offline, which doesn’t come for free but is pretty easy to add once the service worker is set up already.

Of course, service workers have a bunch of pitfalls as well. One is the absolutely abysmal support in developer tools, which seem to intermittently swallow console.log and unreliably report when a service worker is installed. Another is the lack of support for ES modules in Firefox, which forced me to put all my code (including a vendored version of IDB Keyval GitHub - jakearchibald/idb-keyval: A super-simple-small promise-based keyval store implemented with IndexedDB A super-simple-small promise-based keyval store implemented with IndexedDB - jakearchibald/idb-keyval github.com/jakearchibald/idb-keyval , which I included because IndexedDB is similarly annoying) in a single file.

This is not an exhaustive list! I would describe the general experience of working with service workers as “not fun”.

But! In spite of all that, the htmx single-page app works. Let’s dive in!

Behind the Scenes

Let’s start with the HTML:

<!DOCTYPE html>
<html>
  <head>
    <title>htmx spa</title>
    <meta charset="utf-8" />
    <link rel="stylesheet" href="./style.css" />
    <script src="./htmx.js"></script>
    <script type="module">
      async function load() {
        try {
          const registration = await navigator.serviceWorker.register("./sw.js");
          if (registration.active) return;

          const worker = registration.installing || registration.waiting;
          if (!worker) throw new Error("No worker found");

          worker.addEventListener("statechange", () => {
            if (registration.active) location.reload();
          });
        } catch (err) {
          console.error(`Registration failed with ${err}`);
        }
      }

      if ("serviceWorker" in navigator) load();
    </script>
    <meta name="htmx-config" content='{"scrollIntoViewOnBoost": false}' />
  </head>
  <body hx-boost="true" hx-push-url="false" hx-get="./ui" hx-target="body" hx-trigger="load"></body>
</html>

This should look familiar if you’ve ever built a single-page app: the empty husk of an HTML document, waiting to be filled in by JavaScript. That long inline <script> tag just sets up the service worker and is mostly stolen from MDN Using Service Workers - Web APIs | MDN This article provides information on getting started with service workers, including basic architecture, registering a service worker, the installation and activation process for a new service worker, updating your service worker, cache control and custom responses, all in the context of a simple app with offline functionality. developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers .

The interesting bit here is the <body> tag, which uses htmx to set up the meat of the app:

  • hx-boost="true" tells htmx to use Ajax to swap in the responses of link clicks and form submissions without a full page navigation
  • hx-push-url="false" prevents htmx from updating the URL in response to said link clicks and form submissions
  • hx-get="./ui" tells htmx to load the page at /ui and swap it in
  • hx-target="body" tells htmx to swap the results into the <body> element
  • hx-trigger="load" tells htmx that it should do all this when the page loads

So basically: /ui returns the actual markup for the app, at which point htmx takes over any links and forms to make it interactive.

What’s at /ui? Enter the service worker! It uses a small home-brewed Express-like “library” to handle boilerplate around routing requests and returning responses. How that library actually works is beyond the scope of this post, but it’s used like this:

spa.get("/ui", async (_request, { query }) => {
  const { filter = "all" } = query;
  await setFilter(filter);

  const headers = {};
  if (filter === "all") headers["hx-replace-url"] = "./";
  else headers["hx-replace-url"] = "./?filter=" + filter;

  const html = App({ filter, todos: await listTodos() });
  return new Response(html, { headers });
});

When a GET request is made to /ui, this code…

  1. grabs the query string for the filter
  2. saves the filter in IndexedDB
  3. tells htmx to update the URL accordingly
  4. renders the App “component” to HTML with the active filter and list of todos
  5. returns the rendered HTML to the browser

setFilter and listTodos are pretty simple functions that wrap IDB Keyval:

async function setFilter(filter) {
  await set("filter", filter);
}

async function getFilter() {
  return get("filter");
}

async function listTodos() {
  const todos = (await get("todos")) || [];
  const filter = await getFilter();

  switch (filter) {
    case "done":
      return todos.filter(todo => todo.done);
    case "left":
      return todos.filter(todo => !todo.done);
    default:
      return todos;
  }
}

The App component looks like this:

function App({ filter = "all", todos = [] } = {}) {
  return html`
    <div class="app">
      <header class="header">
        <h1>Todos</h1>
        <form class="filters" action="./ui">
          <label class="filter">
            All
            <input
              type="radio"
              name="filter"
              value="all"
              oninput="this.form.requestSubmit()"
              ${filter === "all" && "checked"}
            />
          </label>
          <label class="filter">
            Active
            <input
              type="radio"
              name="filter"
              value="left"
              oninput="this.form.requestSubmit()"
              ${filter === "left" && "checked"}
            />
          </label>
          <label class="filter">
            Completed
            <input
              type="radio"
              name="filter"
              value="done"
              oninput="this.form.requestSubmit()"
              ${filter === "done" && "checked"}
            />
          </label>
        </form>
      </header>
      <ul class="todos">
        ${todos.map(todo => Todo(todo))}
      </ul>
      <form
        class="submit"
        action="./todos/add"
        method="get"
        hx-select=".todos"
        hx-target=".todos"
        hx-swap="outerHTML"
        hx-on::before-request="this.reset()"
      >
        <input
          type="text"
          name="text"
          placeholder="What needs to be done?"
          hx-on::after-request="this.focus()"
        />
      </form>
    </div>
  `.trim();
}

(As before, we’ll skip some of the utility functions like html, which just provides some small conveniences when interpolating values.)

App can be broken down into roughly three sections:

  • The filters form. This renders a radio button for each filter. When a radio button changes, it submits the form to /ui, which re-renders the app using the steps described above. Thehx-boost attribute from before intercepts the form submission and swaps the response back into the <body> without refreshing the page.
  • The todos list. This loops over all the todos matching the current filter, rendering each using the Todo component.
  • The add todo form. This is a form with an input that submits the value to /todos/add.2 hx-target=".todos" tells htmx to replace an element on the page with class todos; hx-select=".todos" tells htmx that rather than using the entire response, it should just use an element with class todos.

Let’s take a look at that /todos/add route:

async function addTodo(text) {
  const id = crypto.randomUUID();
  await update("todos", (todos = []) => [...todos, { id, text, done: false }]);
}

spa.get("/todos/add", async (_request, { query }) => {
  if (query.text) await addTodo(query.text);

  const html = App({ filter: await getFilter(), todos: await listTodos() });
  return new Response(html, {});
});

Pretty simple! It just saves the todo and returns a response with the re-rendered UI, which htmx thens swap into the DOM.

Now, let’s look at that Todo component from before:

function Icon({ name }) {
  return html`
    <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12">
      <use href="./icons.svg#${name}" />
    </svg>
  `;
}

function Todo({ id, text, done, editable }) {
  return html`
    <li class="todo">
      <input
        type="checkbox"
        name="done"
        value="true"
        hx-get="./todos/${id}/update"
        hx-vals="js:{done: event.target.checked}"
        ${done && "checked"}
      />
      ${editable
        ? html`<input
            type="text"
            name="text"
            value="${text}"
            hx-get="./todos/${id}/update"
            hx-trigger="change,blur"
            autofocus
          />`
        : html`<span
            class="preview"
            hx-get="./ui/todos/${id}?editable=true"
            hx-trigger="dblclick"
            hx-target="closest .todo"
            hx-swap="outerHTML"
          >
            ${text}
          </span>`}
      <button class="delete" hx-delete="./todos/${id}">${Icon({ name: "ex" })}</button>
    </li>
  `;
}

There are three main parts here: the checkbox, the delete button and the todo text.

First, the checkbox. It triggers a GET request to /todos/${id}/update every time it’s checked or unchecked, with a query string done matching its current state; htmx swaps the full response into the <body>.

Here’s the code for that route:

async function updateTodo(id, { text, done }) {
  await update("todos", (todos = []) =>
    todos.map(todo => {
      if (todo.id !== id) return todo;
      return { ...todo, text: text || todo.text, done: done ?? todo.done };
    })
  );
}

spa.get("/todos/:id/update", async (_request, { params, query }) => {
  const updates = {};
  if (query.text) updates.text = query.text;
  if (query.done) updates.done = query.done === "true";

  await updateTodo(params.id, updates);

  const html = App({ filter: await getFilter(), todos: await listTodos() });
  return new Response(html);
});

(Notice that the route also supports changing the todo text. We’ll get to that in a minute.)

The delete button is even simpler: it makes a DELETE request to /todos/${id}. As with the checkbox, htmx swaps the full response into the <body>.

Here’s that route:

async function deleteTodo(id) {
  await update("todos", (todos = []) => todos.filter(todo => todo.id !== id));
}

spa.delete("/todos/:id", async (_request, { params }) => {
  await deleteTodo(params.id);

  const html = App({ filter: await getFilter(), todos: await listTodos() });
  return new Response(html);
});

The final part is the todo text, which is made more complicated by the support for editing the text. There are two possible states: “normal”, which just displays a simple <span> with the todo text (I’m sorry that this isn’t accessible!) and “editing”, which displays an <input> that allows the user to edit it. The Todo component uses the editing “prop” to determine which state to render.

Unlike in a client-side framework like React, though, we can’t just toggle state somewhere and have it make the necessary DOM changes. htmx makes a network request for the new UI, and we need to return a hypermedia response that it can then swap into the DOM.

Here’s the route:

async function getTodo(id) {
  const todos = await listTodos();
  return todos.find(todo => todo.id === id);
}

spa.get("/ui/todos/:id", async (_request, { params, query }) => {
  const todo = await getTodo(params.id);
  if (!todo) return new Response("", { status: 404 });

  const editable = query.editable === "true";

  const html = Todo({ ...todo, editable });
  return new Response(html);
});

At a high level, the coordination between webpage and service worker looks something like this:

  1. htmx listens for double-click events on todo text <span>s
  2. htmx makes a request to /ui/todos/${id}?editable=true
  3. The service worker returns the HTML for the Todo component that includes the <input> rather than the <span>
  4. htmx swaps the current todo list item with the HTML from the response

When the user changes the input, a similar process happens, calling the /todos/${id}/update endpoint instead and swapping the whole <body>. If you’ve used htmx, this should be a pretty familiar pattern.

That’s it! We now have a single-page app built with htmx (and service workers) that doesn’t rely on a remote web server. The code I omitted for brevity is available on GitHub GitHub - jakelazaroff/htmx-spa: A proof of concept fully client-side single-page app using htmx and service workers. A proof of concept fully client-side single-page app using htmx and service workers. - jakelazaroff/htmx-spa github.com/jakelazaroff/htmx-spa .

Takeaways

So, this technically works. Is it a good idea? Is it the apotheosis of hypermedia-based applications? Should we abandon React and build apps like this?

htmx works by adding indirection to the UI, loading new HTML from across a network boundary. That can make sense in a client-server app, because it reduces indirection with regard to the database by colocating it with rendering. On the other hand, the client-server story in a framework like React can be painful, requiring careful coordination between clients and servers via an awkward data exchange channel.

When all interactions are local, though, the rendering and data are already colocated (in memory) and updating them in tandem with a framework like React is easy and synchronous. In this case, the indirection that htmx requires starts to feel more burdensome than liberatory.3 For fully local apps, I don’t think juice is worth the squeeze.

Of course, most apps aren’t fully local — usually, there’s a mix of local interactions and network requests. My sense is that even in that case, islands of interactivity Islands Architecture The islands architecture encourages small, focused chunks of interactivity within server-rendered web pages www.patterns.dev/vanilla/islands-architecture/ is a better pattern than splitting your “server-side” code between the service worker and the actual server.

In any event, this was mostly an exercise to see what it might look like to build a fully local single-page app using hypermedia, rather than imperative or functional programming.

Note that hypermedia is a technique rather than a specific tool. I chose htmx because it’s the hypermedia library framework </> htmx ~ Is htmx Just Another JavaScript Framework? htmx gives you access to AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext htmx is small (~14k min.gz’d), dependency-free, extendable, IE11 compatible & has reduced code base sizes by 67% when compared with react htmx.org/essays/is-htmx-another-javascript-framework/ du jour, and I wanted to stretch it as far as I could. There are other tools like Mavo Mavo: A new, approachable way to create Web applications mavo.io that explicitly focus on this use case, and indeed you can see that the Mavo implementation of TodoMVC To-Do List mavo.io/demos/todo/ is far simpler than what I’ve built here. Better still would be some sort of HyperCard-esque app in which you could build the whole thing visually.

All in all, my little single-page htmx todo app was fun to build. If nothing else, take this as a reminder that you can and should occasionally try using your tools in weird and unexpected ways!

Footnotes

  1. React developers hate him!

  2. You might notice that the form method is GET rather than POST. That’s because service workers in Firefox don’t seem to support request bodies, which means we need to include any relevant data in the URL.

  3. htmx isn’t actually a required component of this architecture. You could, in theory, build a fully client-side single-page app with no JavaScript at all (outside of the service worker) by simply wrapping every button in a <form> tag and replacing the full page on every action. Since the responses all come from the service worker, it would still be lightning fast; you could probably even add in some slick animations using cross-document view transitions Smooth transitions with the View Transition API  |  View Transitions  |  Chrome for Developers The View Transition API lets you add transitions between views of a website. developer.chrome.com/docs/web-platform/view-transitions#cross-document_view_transitions .