The previous sections argued for hypermedia-driven architecture on structural grounds: coupling, migration cost, backward compatibility. This section puts code next to code. What does the same feature actually look like when built both ways, and what do published migrations tell us about the difference at scale?
What real migrations show
The strongest published data comes from Contexte, a SaaS product for media professionals built with React. In 2022, developer David Guillot presented the results of porting the application from React to Django templates with htmx:
| Metric | React | Django + htmx | Change |
|---|---|---|---|
| Total lines of code | 21,500 | 7,200 | −67% |
| JavaScript dependencies | 255 | 9 | −96% |
| Web build time | 40s | 5s | −88% |
| First load time-to-interactive | 2–6s | 1–2s | −50–60% |
| Memory usage | ~75 MB | ~45 MB | −46% |
The port took roughly two months to rewrite a codebase that had taken two years to build. The team eliminated the hard split between frontend and backend developers. User experience did not degrade.
Contexte is a media-oriented application, exactly the kind of content-driven, read-heavy workload that hypermedia was designed for. The htmx project acknowledges this: “These sorts of numbers would not be expected for every web application.” A separate Next.js to htmx port showed a 17% reduction in written application code and over 50% reduction in total shipped code when accounting for dependency weight.
The pattern across these migrations is consistent. The JSON serialisation layer disappears. Client-side state management disappears. The build toolchain disappears. The dependency graph collapses. What remains is server-side code that got somewhat larger (Contexte’s Python grew from 500 to 1,200 lines) and a total codebase that got dramatically smaller.
The same feature, two architectures
Consider a searchable contact list with inline editing and deletion. The specification is identical for both implementations:
- Display contacts from a database
- Live search with debounce (300ms)
- Click a row to get an editable form
- Delete with confirmation
- All changes persist to the server
This is a bread-and-butter CRUD feature. Most web applications are made of features like this one.
SPA: React + Vite + REST API
The SPA approach requires two applications. A React client handles rendering and state. A server exposes JSON endpoints. They communicate through a serialisation boundary.
Search with debounce needs a custom hook or a library:
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
function ContactList() {
const [query, setQuery] = useState('');
const [contacts, setContacts] = useState([]);
const [editingId, setEditingId] = useState(null);
const debouncedQuery = useDebounce(query, 300);
useEffect(() => {
fetch(`/api/contacts?q=${debouncedQuery}`)
.then(res => res.json())
.then(setContacts);
}, [debouncedQuery]);
// ... render logic, edit mode toggling, delete handlers
}
The component manages three pieces of state: the search query, the contact list, and which row is being edited. Each state change triggers a re-render. The search query flows through a debounce hook, which triggers a fetch, which deserialises JSON, which updates state, which triggers another re-render. The edit mode is a client-side toggle: clicking a row sets editingId, and the component conditionally renders either a display row or a form row based on that state.
Inline editing requires the client to manage form state, submit JSON to the API, handle the response, and update the local contact list to reflect the change:
async function handleSave(contact) {
const res = await fetch(`/api/contacts/${contact.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(contact),
});
const updated = await res.json();
setContacts(prev =>
prev.map(c => c.id === updated.id ? updated : c)
);
setEditingId(null);
}
The server side mirrors this with JSON endpoints:
app.get('/api/contacts', async (req, res) => {
const contacts = await db.query(
'SELECT * FROM contacts WHERE name ILIKE $1',
[`%${req.query.q}%`]
);
res.json(contacts);
});
app.put('/api/contacts/:id', async (req, res) => {
const { name, email } = req.body;
const updated = await db.query(
'UPDATE contacts SET name = $1, email = $2 WHERE id = $3 RETURNING *',
[name, email, req.params.id]
);
res.json(updated[0]);
});
Every interaction crosses the serialisation boundary twice: the server serialises to JSON, the client deserialises, processes the data, and re-renders. The CORS configuration, the Content-Type headers, the JSON.stringify and res.json() calls are all infrastructure that exists solely because the client and server are separate applications communicating through a data format that carries no hypermedia controls.
The project also needs a build toolchain. A fresh React + Vite project installs Node.js, npm, Vite (which bundles esbuild for development and Rollup for production), a JSX transformer (Babel or SWC), and ESLint. The node_modules directory contains hundreds of transitive packages. Each is a separate project with its own release cycle.
HDA: Rust/Axum/Maud + htmx
The HDA approach is one application. The server handles everything: routing, data access, rendering, and interactivity declarations.
Search with debounce is a single HTML attribute:
fn search_input(query: &str) -> Markup {
html! {
input type="text" name="q" value=(query)
hx-get="/contacts"
hx-trigger="input changed delay:300ms"
hx-target="#contact-list"
placeholder="Search contacts...";
}
}
No hook. No state. No effect. The hx-trigger attribute declares the debounce behaviour inline. When the user types, htmx waits 300ms after the last keystroke, sends a GET request, and swaps the response into #contact-list. The server returns an HTML fragment containing the filtered rows.
The search handler queries the database and renders HTML directly:
async fn list_contacts(
State(pool): State<PgPool>,
Query(params): Query<SearchParams>,
) -> Markup {
let contacts = sqlx::query_as!(
Contact,
"SELECT * FROM contacts WHERE name ILIKE '%' || $1 || '%'",
params.q.unwrap_or_default()
)
.fetch_all(&pool)
.await
.unwrap();
html! {
tbody#contact-list {
@for contact in &contacts {
(contact_row(contact))
}
}
}
}
There is no JSON serialisation. The handler returns Markup, which Axum sends as an HTML response. The database result flows directly into the template. The query is checked at compile time by SQLx.
Inline editing is a template swap, not a state toggle. Clicking the edit button asks the server for an edit form:
fn contact_row(contact: &Contact) -> Markup {
html! {
tr {
td { (contact.name) }
td { (contact.email) }
td {
button hx-get={"/contacts/" (contact.id) "/edit"}
hx-target="closest tr"
hx-swap="outerHTML" { "Edit" }
}
}
}
}
fn contact_edit_row(contact: &Contact) -> Markup {
html! {
tr {
td {
input type="text" name="name" value=(contact.name);
}
td {
input type="text" name="email" value=(contact.email);
}
td {
button hx-put={"/contacts/" (contact.id)}
hx-target="closest tr"
hx-swap="outerHTML"
hx-include="closest tr" { "Save" }
}
}
}
}
The edit handler returns contact_edit_row, which replaces the display row. The save handler updates the database and returns contact_row, which replaces the edit form. No client-side state tracks which row is being edited. The server controls the UI by returning the appropriate HTML fragment.
The entire client-side dependency is htmx: a single 14 KB file (minified and gzipped) with zero dependencies. No build step. No node_modules. No package manager. Vendor the file and serve it from your Rust application.
Key observations
The comparison reveals differences that are structural, not incremental.
The JSON serialisation layer is eliminated entirely. In the SPA, every interaction crosses a serialisation boundary: JSON.stringify on the client, res.json() on the server, res.json() then setContacts() on the way back. In HDA, the handler returns HTML. The serialisation layer does not exist because the architecture does not need it.
Client-side state management disappears. The React component manages query, contacts, and editingId as state. Changes to any of these trigger re-renders. The htmx version has no client-side state at all. The server is the single source of truth, and every user action asks the server what to show next.
The dependency asymmetry is categorical. One side installs hundreds of packages through a package manager, maintained by hundreds of independent maintainers, each a potential supply chain risk. The other vendors a single file. The React runtime alone (~55 KB gzipped for React 19 + ReactDOM) is roughly four times the size of htmx (~14 KB gzipped), and that comparison ignores the entire build toolchain and its transitive dependencies.
The build toolchain is a complexity tax. The SPA needs Node.js, npm, Vite, esbuild, Rollup, and a JSX transformer to convert source files into something a browser can execute. The HDA serves HTML from a compiled Rust binary. The browser needs no build artefact because the server already produced what the browser understands natively: HTML.
What the SPA provides that HDA does not
The comparison above is favourable to HDA because this is a CRUD feature, and CRUD features are what HDA handles best. The SPA architecture has genuine strengths that should not be dismissed as irrelevant.
Component-level encapsulation with typed props. React components accept typed props and manage their own state in a well-defined scope. This composability model is genuinely powerful for building complex UIs. A component can be tested in isolation, rendered in a storybook, and reused across pages with different data. Maud functions provide similar composition, but the pattern is less formalised and has no equivalent to React’s developer tooling for component inspection.
React DevTools and the debugging experience. React DevTools lets you inspect the component tree, view props and state, trace re-renders, and profile performance. The htmx debugging experience is the browser’s network tab and the DOM inspector. For complex UIs, React’s tooling gives developers significantly better visibility into what the application is doing and why.
Client-side rendering avoids some server round-trips. When edit mode is a client-side state toggle, the UI updates instantly. No network request is needed to show a form. In HDA, clicking “Edit” sends a request to the server and waits for the response. On a fast connection, this difference is imperceptible. On a slow connection or for highly interactive interfaces, it matters.
The component library ecosystem is unmatched. Libraries like shadcn/ui and Radix provide production-quality, accessible UI primitives: dialogs, dropdowns, date pickers, data tables, command palettes. These components handle keyboard navigation, screen reader announcements, focus trapping, and edge cases that take significant effort to implement correctly. The HDA ecosystem has no equivalent at comparable maturity. If your application needs a complex, accessible data table with column sorting, filtering, pagination, and row selection, a React component library gives you that out of the box.
TypeScript provides end-to-end type checking. TypeScript catches errors across the entire client-side codebase: props, state, API response shapes, event handlers. In the SPA model, a type error in a component is caught before the code runs. Rust provides this same safety on the server side (and Maud catches malformed HTML at compile time), but the client-side interactivity in HDA is untyped HTML attributes. A typo in hx-target is a runtime error, not a compile-time error.
Hiring and ecosystem momentum. React dominates job postings and developer mindshare. Finding developers who know React is straightforward. Finding developers who know Rust, Axum, Maud, and htmx is harder. This is not a technical argument, but it is a practical one that affects team building and hiring timelines.
For most CRUD and content-driven features, these trade-offs favour HDA. The component ecosystem advantage matters most when building interfaces that require complex, accessible widgets. The typing advantage is real but narrower than it appears, because the majority of interactivity in an HDA is handled by a small set of well-tested htmx attributes rather than arbitrary JavaScript. The hiring argument is genuine and may be the strongest practical objection for many teams.
Rust-specific advantages
The contact list comparison used generic server code for the SPA side. The HDA side is Rust, and Rust brings specific advantages beyond the architectural ones.
Maud checks HTML at compile time. Most server-side template engines (Jinja2, ERB, Handlebars) parse templates at runtime. A typo in a variable name, a missing closing tag, or a type mismatch surfaces as a runtime error, sometimes only when that specific template path is hit in production. Maud’s html! macro is evaluated during compilation. If the template contains a syntax error or references a variable that does not exist, the code does not compile. This is a meaningful safety guarantee that most server-side frameworks cannot offer.
SQLx checks queries at compile time. The sqlx::query_as! macro verifies SQL against a live database during compilation. If a column name is wrong, a type does not match, or a table does not exist, the compiler catches it. Combined with Maud’s compile-time HTML checking, the Rust HDA stack catches errors at two boundaries (database-to-code and code-to-HTML) where most stacks only discover problems at runtime.
The combination delivers type safety comparable to TypeScript + React, but without the client-side dependency graph. TypeScript checks component props and state. Rust + SQLx + Maud checks database queries, handler types, and HTML output. Both approaches catch a broad category of errors before the code runs. The difference is that the Rust approach achieves this with a single compiled binary, while the TypeScript approach requires a build toolchain, a runtime, and hundreds of dependencies to deliver the same guarantee.