Why the default web architecture is more complex than it needs to be —
and what happens when you use the platform as designed.
"What if the web already has
an application protocol —
and we just stopped reinventing it?"
The standard architecture for a modern web app.
6 layers of indirection to turn database rows into something the user can see. Each layer has its own abstractions, error handling, caching, and testing strategy.
TanStack Query, Redux, Zustand — all exist because JSON doesn't tell the browser how to render itself.
OpenAPI → generated types → generated hooks → versioned packages. Breaks when the schema changes.
React, Vue, Svelte — they exist to solve a problem HTML already solves: turning data into pixels.
One screen = multiple API calls → client composition → loading states → error boundaries.
Client caches go stale. Invalidations propagate. Realtime updates trigger re-fetches.
React 16→17→18→19. Next.js Pages→App Router→RSC. Weeks of engineering time each time.
One layer. The server composes the data, renders the HTML, and sends it.
4 steps, zero codegen, zero client state. The browser's native rendering engine does what React does — for free.
One button. One GET request. The server returns an HTML fragment. HTMX swaps it in. That's it.
#htmx-click-result. The network log above shows every detail — method, URL, target, swap strategy, and the actual HTML response.
Every action sends a request. The server returns updated HTML. The list re-renders. Zero client state.
The HTML for a toggle button:
<button hx-put="/api/todos/1/toggle" hx-target="#todo-list" hx-swap="innerHTML" >✓</button>
The form:
<form hx-post="/api/todos" hx-target="#todo-list"> <input name="text"/> <button>Add</button> </form>
Working demo — go ahead:
The server opens an SSE connection and pushes HTML fragments word by word. The browser applies them as they arrive. This is Datastar's model.
event: datastar-merge-fragments with an HTML fragment. Datastar patches the DOM. Watch the network log above — each event appears as it's pushed.
Server-Sent Events give you realtime updates with native browser support. Durable Objects give you stateful coordination at the edge.
Last-Event-IDLast-Event-ID header + DO's in-memory event log = cursor resume without building a subscription system from scratch.
Every handler runs inside an Effect pipeline — tagged error unions, dependency injection, structured concurrency, observability for free.
Typed errors → compiler catches everything:
const renderError = Effect.catchTags({ NotFoundError: (e) => html(<EmptyState title={...} />), ValidationError: (e) => html(<FormErrors fields={...} />), RateLimitedError: (e) => html(<RateLimitBanner />), ...// missing a tag? Compile error. });
Service layers — test by swapping:
// Production: D1 database const Live = UserService.Live // Test: in-memory fixtures const Test = UserService.Test // Same routes, same type signatures // — different backing services app.get("/api/users/:id", authenticate(), async (c) => { const program = Effect.gen(function* () { const svc = yield* UserService; const user = yield* svc.getProfile(id); return c.html(<Profile {...user} />); }).pipe(renderError); } );
In a hypermedia architecture, error handling is just rendering. The server always returns HTML.
| Requirement | JSON-first | Hypermedia |
|---|---|---|
| Wire format | JSON over RPC | HTML + SSE |
| Client state | TanStack / Redux | DOM is state |
| Invalidation | Event signals → re-fetch | SSE → HTMX auto-refetch |
| Error handling | Typed JSON → client switch | Tagged errors → HTML |
| Realtime | WebSocket + cursor protocol | SSE + DO + Last-Event-ID |
| Codegen | Required | None |
| Bundle | ~180kb | ~30kb |
| Framework lock-in | Next.js / React / Vue | None |
// 1. Schema export const op = { input: z.object({ id: z.string() }), output: MetricsSummary, policy: { roles: ['admin'] }, }; // 2. Codegen'd hook (auto) function useMetrics() { ... } // 3. React component function Panel() { const { data, error } = useMetrics(); if (error) return <Error />; return <View data={data} />; } // 4. API seam export async function POST(req) { const actor = await resolve(req); enforce(actor, schema.policy); return backend.metrics(input); }
// 1. HTML <div hx-get="/api/metrics" hx-trigger="load, refresh from /sse" hx-swap="innerHTML" >Loading...</div> // 2. Route handler app.get("/api/metrics", authenticate(), authorize('admin'), async (c) => { const data = yield* svc.getMetrics(id); return c.html( <MetricsPanel {...data} /> ); } );
React-native teams need to learn a different mental model. The learning curve is short, but it IS a shift.
Drag-and-drop, rich text, interactive canvases — these need Alpine.js or light JS supplements.
No compile-time type safety from schema to rendered output. The trade: zero codegen complexity.
HTMX works alongside existing JS. Adopt route by route — start with one panel, one form.
The bet is that fewer moving parts outweighs the loss of compile-time wire types. In 25 years of building production systems, that bet has paid off more often than not.
"The web already has
an application protocol.
Use it."
Stu Kennedy
HTMX Core Contributor · stu@stukennedy.com
Built with conviction. Open to being wrong.