Code. Code by Symbolon from the Noun Project


It was authentication that put me over the edge.

I maintain a personal budgeting app. It’s more than 10 years old, and while perfectly functional I don’t like the way I set up the data model back then, which mimicked the Excel spreadsheet it replaced.

Do I need to rewrite it? No. But I’m bored. And I thought to myself: self, this seems like a good little project to finally do as an SPA.

As you probably already know I am a curmudgeon and none of my side projects want to be SPAs. But I’ve lost this argument, so I might as well move on and join the herd.

So I wrote the new data model, importer to convert old data to the new streamlined structure, API layer, put it all in a platform folder and fired up Next.js.

Next is great. Really, I’m incredibly impressed with what they’ve managed to pull off.

Now how do I login?


Apparently the cool kids are all doing something called a JWT. There’s a Django Rest Framework plugin or two for that, so that sounds good.

You’re advised to ping the JWT endpoint, get the value, and store it… somewhere? But the details on this are fuzzy.

Tomorrow morning I’m going to wake up and work on adtech stuff. Because of this, I’m a little more aware than the average developer that you should never, ever store a password in the browser. Despite myths and assumptions to the contrary, third party javascript can pipe all your LocalStorage and Cookies to literally anywhere they want. Even storing it in memory isn’t totally safe when you remember javascript is chaos and one can monkey-patch prototypes.

The correct way to do this is still an HTTP Only Cookie, which will go out with API calls but can’t be read by Javascript. No problem. Except also, yes there’s a problem: most JWT libraries don’t actually support this since they’re designed to be used across domain names.

This all feels like something you’re not supposed to do. Maybe I’m just not very smart and some 23-year-old prodigy at Google figured this out, but I couldn’t see how, and there’s a reason these security measures are almost universal.

Still, I can at least try to make a Cookie work, ping my API on pageload, make sure I have access, show the login screen if not.

Since third party cookies are dying anyway this should all live on one domain. I spun up Caddy (a convenient proxy server if you ever just need HTTPS locally) to bridge the two apps.

Then I got stuck for two hours managing state because it’s surprisingly tricky to get Next.js to set logged in state on the server and persist it in the client and future requests. I got it working only to discover the JWT cookie duration was hardcoded at 5 minutes with no setting to change it.

In trying to look into how other people deal with this nonsense—seriously I should just write an API endpoint that does conventional login and call it done—I stumbled across a thread from last summer where the Django core team was discussing how the core design of the JWT is a security nightmare, and all these tutorials keep cargo-culting it in spite of its very deep flaws.


There may be a case for this in apps—that’s not my area of expertise. Don’t use it as prescribed for the web.


What am I doing with my life?

For a toy project, this is no fun. I’ve spent hours on this and so far I’m trying to wrangle other people’s messy design decisions.

Besides, if you find yourself in old Django or Rails forums, lot’s of developers are still doing things the old fashioned way. It isn’t cool, but it’s pretty damn effective.

Thinking about this, I couldn’t help but notice the entire culture of software changed in the past few years, and the most popular frameworks are now complicated feats of engineering built by megacorporations that you will only understand if you rearrange your brain.1

I go back and forth: do we need this?

What if, instead of trying to make this stack work for my middling CRUD-calculator, I attempt a challenge I actually like:

Let’s build a web app with nothing.

HTML, CSS, Javascript. Use the latest APIs modern browsers have to offer, but no frameworks or build tools at all.


Consider the implications.

No npm. No asset pipeline. No automatic code splitting.

On the other hand, browsers have implemented all sorts of cool new stuff. I can use CSS variables and grid in ways I couldn’t imagine back in the early days of SASS. I have fetch, async/await, native HTML draggable, and can import ES modules over HTTP2. There’s a robust CSS animation API to make it all flashy. In theory, the kind of interactions and experiences that pushed developers into complicated javascript back in the day should be much easier to implement now.

Looking at Basecamp’s HTML Over the Wire2 and HTMX, I saw another pattern. In most applications, the real requirement for modern apps is not reacting on constantly changing state: it’s swapping out chunks of the page independently. And in some cases, it’s swapping out the content of entire pages without a reload.

That’s not so hard, is it?


So I made a new branch, rm -rf node_modules and ventured out into the abyss, starting with the dashboard—a huge grid of expenses per month calculated by category.3

With CSS Grid, I found myself needing fewer media queries than I did the last time I built this, which makes nesting them less painful. With CSS variables, I wind up cleaning up not just the CSS but the template logic.

I wound up with one app.css, plus about one or two more CSS files per page. I’m not concatenating them because without a build process, I can’t.

But do I need to? HTTP2 isn’t perfect, but it’s far more forgiving of requests to the same server. I wouldn’t load 100 CSS files, but would a handful hurt anything? My lighthouse score is 100 on every page without even trying.

With enough UI built to test it, I took a cue from Hotwire’s frames, wrapped the swappable things in a made up element called <zip-wire>, each of which has an ID.

Then all it needed was a button:

<button data-wire-href="/?same-page-new-parameters" data-wire-selector="#dashboard"></button>

The attributes specify what url to load, and what regions of the page it will update.

// Early proof of concept. The finished version has more abstractions for forms, POST, redirects, etc.
export async function zipWireLoad(url, selector) {
  selector = selector || "zip-wire";
  const html = await fetch(url).then((resp) => resp.text());
  const parser = new DOMParser();
  const dom = parser.parseFromString(html, "text/html");

  // Update the page title
  const title = dom.querySelector("title").innerText;

  // Push this into the history
  document.querySelector("title").innerText = title;
  history.pushState(null, title, url);

  const newFrames = [...dom.querySelectorAll(selector)].map(
    async (replacement) => {
      const existingFrame = document.getElementById(;

      // Handle HTML
      existingFrame.innerHTML = replacement.innerHTML;

      return existingFrame;
  console.debug(`Updating frames for`, newFrames);

That will fetch a whole page, and update expected sections (or all content for navigation links).

This works but it’s not complete. The updated components might need to load CSS and JS. This is where stuff gets weird, because I’ve created a world where I need to keep my static files referenced in my components in order to pull them in over AJAX. I can defer scripts, preload links, but the DOM elements have to be inside the <zip-wire> wrappers if this AJAX loading is going to work.

For scripts, everything is an ES module, and import already has its own cache.

// Handle scripts. Since import caching automatically
// we don't need to worry about repeats.
[...replacement.querySelectorAll("script[src]")].forEach((script) => {
  console.debug(`Loading script for`, script);

CSS is slightly more complicated. It needs to render before the HTML changes, or the page will flicker. I wound up grabbing the <link> tags, checking that there’s nothing in the <head> that matches them already (a de-facto session cache), appending them there, and then await their load events before updating the HTML.

Add in a bouncing “loading” status with some CSS animations and the whole thing feels very slick. It is, for all practical purposes, an SPA, without the overhead of a big framework.4


Since this is all really just a progressively enhanced multi-page web app, by definition every meaningful state is a distinct URI. This means the whole app maps nicely into the history API: each action populates it, and the back button will always update the whole body from that past position.

Even better, fetch will handle 302’s and 307’s5 seamlessly. This means if I post a form to the server and get back a redirect, the response object contains everything I need to update the HTML and history with.

  const resp = await fetch(url, { method, body });
  console.log(resp.redirected)  // I don't actually need to know, but I can?!
  const html = await resp.text();
  history.pushState(null, "Get the title from HTML", resp.url);

That’s cool. And even with all these features fleshed out, the file size was still below 2.9kb of javascript with gzip.


This is a small app. It’s makes sense as a single Django service, but given this is really a frontend experiment, there’s nothing to stop the same strategies from working pretty well on an Express or Flask app that talks to multiple backends.


Around the time finished all the basics I realized I need some kind of interface to tell javascript when a new section renders. Setting up and forgetting event delegate on first run works for clicks, but form field toggling magic or setting up drag and drop UI needed to run each time a new component appeared on the page.

I wound up adding a data-zip-wire-event="eventName" that I could place on DOM elements, telling the app to fire custom events after it renders a component and ensures its scripts are in place. Yes, this is an on-component-mount event, and somewhere on the wind I could hear the ghost of Dan Abramov laughing maniacally as I gradually reinvent the wheel.


Aside from the obvious caveats that come from designing your own patterns as you go, there are a few serious downsides.

The connections between all these modules are all implicit. I have buttons with data attributes referring to ID’s, it all needs to match up, and your linters will not help you with this. Frankly, that’s web development. CSS, HTML, and Javascript have always interacted this way.

Can this become messy at scale? Absolutely. Large apps require more discipline, regardless of the stack. There’s a reason big organizations like TypeScript.

Compatibility is still an issue, even though it’s much easier than it was just a few years ago. While modern browsers behave roughly the same 98% of the time (just check your MDN compatibility tables), that last 2% is brutal. I have a piece of UI that makes sense as a drag and drop sortable list. Getting it to work in Chrome with vanilla JS was no big deal. But apparently there’s a decade-old bug in Firefox’s HTML draggable implementation, and no trivial solution works on iOS browsers.

I wound up backing down from this and implementing with all clicks and some visual affordances.

And to be fair, some things are in fact easier with a first-class component system. If you want to make a sliding red “are you sure you want to delete this” switch, it really is better to be able to write it once and drop a <DeleteSwitch /> in the form. I considered trying to capture the spirit of that effect with WebComponents, which would be cool, but settled on Jinja macros in most of those cases to avoid going down yet another rabbit hole.


Should you build stuff like this this?

As part of a bigger team, probably not. I may do dumb, out-there experiments on my own time, but when there’s money and a whole group of engineers involved, the best code is boring, predictable, and approachable.

That said, while I intentionally went overboard to see if I could,6 this isn’t necessarily an all or nothing proposition, and purity is a fool’s errand. In practice a small team building an app that isn’t hyper reactive to state (e.g., anything but Slack or Notion), you could readily sub in Hotwire or Vue for a lightweight navigation layer with some progressively enhanced components, or apply @ts-check to your javascript if that’s your thing.

As a way to upgrade a personal piece of software and waste time in the pandemic, this was kind of great. It’s fast, the UI is slick, I learned some new things, and was really pleased how fast I could turn around features that clicked cleanly into place once I had the basic patterns set up.

Meanwhile, while I was working on this, Tom McWright (known for Observable) came out with a very similar post, going back from Next.js to Rails, I’ve stumbled across a dozen notes from Python and Ruby devs basically saying the same sort of thing, and A List Apart ran “The Future of Web Software Is HTML-over-WebSockets.”7 What’s cool about this is it’s not just techno-curmudgeonry; they’re talking about making things in new and creative ways, and they sound genuinely excited about it.

I’m not making any wild conclusions based on that, and to be clear, I’m not even critiquing the SPA ecosystem in this post beyond a very narrow issue around missing some key authentication patterns.8

But I can see the itch.

The itch is real. The itch is interesting.

See All Writing