HTML-first or SPA: Reset the Default, Skip the Holy War

Whenever someone asks whether a project “should be a SPA”, I want to ask one thing back first: where does this screen’s state actually live, server or client? Most people can’t answer, because they never thought about it. The reflex is to run create-vue or create-react-app and move on. The misplaced default is the real problem, not the SPA itself.

After htmx 2.0 shipped in mid-2024, the server-first path came back into the conversation. It brought no new magic. The core hasn’t moved since day one: send an HTML fragment, keep state on the server, let the browser swap part of the DOM. The point isn’t how clever htmx is. It’s that the tool forces a question people keep skipping: does this interaction really need state moved to the front end?

Where state lives is where the answer is

My own test is simple, just two questions. First, does this screen’s state mostly live on the server or the client? Second, is the interaction “swap a block of content” or “drive a lot of local state in real time”?

CRUD, forms, workflow-heavy admin tools: state lives on the server almost every time. The user clicks, sends a request, the back end computes and returns some HTML, the view changes. Force a SPA onto that and you’ve handed yourself a JSON API layer, a state store, two copies of the model to keep in sync, and serialisation on both ends. What did all that complexity buy you? A “create one record” flow that a plain form submit already handled. That’s textbook over-engineering: wrapping something simple until it looks hard.

Server-first wins here not because it’s fashionable, but because the responsibility boundary stays clean. There’s one copy of state, it lives on the server, and the front end is just its projection. No fretting over two sides drifting apart, no stale client cache, no writing a reducer for a dropdown. That’s the value of htmx and Hotwire: you keep your HTTP and DOM instincts instead of learning a framework’s whole worldview before you can change a button.

The cases that genuinely need client state

That said, I’m not here to dismiss the SPA. There’s a class of work where state naturally lives on the client, and shoving it back onto the server is the disaster.

High-interaction editors, canvas drawing, drag-and-drop layout: every mouse move is a state change, and you can’t fire a request on each one. Offline-first apps are even clearer; the user has no network, so state has to live locally first. And plenty of today’s local-first SaaS writes data into IndexedDB and keeps a copy of state in memory, where CSR reaches near-native smoothness. Server-first there is asking for pain.

The test is still those two questions. If the interaction is “drive a lot of local state in real time”, state mostly lives on the client, and client state management is the cost you should pay, gladly. Reach for Svelte or React, wire up runes or signals for reactivity, all reasonable. The point is you chose it because the screen genuinely needs it, not because your hand slipped onto a create command.

My own scars are concrete. One project started on SvelteKit SSR and was just slow in certain environments; switching to a pure SPA made it smooth, because that screen was interaction-heavy and its state belonged on the front end anyway. The reverse happened too: for an admin console I insisted on server routes as SSR plus a BFF, keeping external requests away from the data layer, and the front-end complexity it saved was almost absurd. Same person, same year, two opposite calls, separated only by how those two questions came out.

Reset the default to HTML-first

What I’m getting at is one thing the whole way through: the default. Reflexively reaching for a full SPA is a misplaced default, not because the SPA is wrong, but because you skipped the judgement. Resetting to HTML-first means you start by assuming state lives on the server and the interaction swaps content, build it the thinnest way possible, and upgrade to the client only when you actually hit a screen that’s heavy on local state.

This isn’t a one-or-the-other holy war. It’s two modes coexisting inside one project. The admin CRUD runs on htmx, the high-interaction editor runs as a SPA, no contradiction. The contradiction is forcing everything into the same heavyweight framework and then paying the SPA tax on every trivial form. Default to the cheap path, upgrade when needed, and your complexity ends up spent where it counts.

Key takeaways

  • Ask two questions first: does state mostly live on the server or the client, and does the interaction swap content or drive a lot of local state.
  • CRUD, forms, and workflow-heavy admin keep state on the server; server-first gives clean boundaries and one copy of state.
  • High-interaction, offline-first, canvas, and local-first work keep state on the client by nature; don’t force htmx on them.
  • Reflexively reaching for a full SPA is a misplaced default; reset to HTML-first and upgrade only when needed.
  • One project can run both modes; spend complexity on the screens that genuinely need it.

Sheng’s take, drafted with Claude · part of the 2026-06-13 blog renovation, paint still drying.