Hathor is a CRUD admin panel for a national vehicle registry (Sobek). It displays ~2-3 entity types (VehicleTypes, DeckPlans) in data tables with sidebar editing, search, and an XML import wizard. The user dislikes React and wants to know if HTMX could simplify things.
The codebase is ~6,400 LOC across 101 files. For what amounts to 2 data tables, a search bar, and an import wizard, that's a lot of machinery. Here's what's genuinely complex vs accidental complexity:
Accidental complexity (React tax):
- 5 nested context providers just to pass config/auth/search state down
- 12 custom hooks, most of which are thin wrappers around
useState+useEffect ViewConfiggeneric pattern with 11+ type parameters for... 2 entity viewsuseDataViewTableLogicdoes client-side sort/filter/paginate on data that could come pre-sorted from the serveruseUrlFiltersbidirectional URL sync — 69 lines ofuseEffectchoreography for whathx-push-urldoes in one attributeSearchContextat 197 lines — a debounced text input with filter state
Genuinely complex client-side interactions:
- Resizable sidebar panels (mouse drag tracking) — needs JS, but ~40 lines of vanilla JS
- Multi-step import wizard with XML parsing (
MultiImport.tsx, 276 lines) — real state machine - Drag-and-drop file upload — ~30 lines of vanilla JS
- Runtime theme switching — nice-to-have, not essential
Yes, significantly — IF you control the backend. That's the real question.
| Feature | React (current) | HTMX equivalent |
|---|---|---|
| Data table | GraphQL fetch → client sort/filter/paginate → render | hx-get="/vehicles?sort=name&page=2" → server returns <tbody> |
| Search | 197-line SearchContext + debounce + suggestions | hx-get="/search?q=..." hx-trigger="input changed delay:300ms" hx-target="#results" |
| Pagination | Client-side slice of full dataset | hx-get="/vehicles?page=3" hx-swap="innerHTML" |
| Sorting | Client-side re-sort | hx-get="/vehicles?sort=name&dir=asc" on column header click |
| Filters | useUrlFilters + SearchContext + useEffect dance | hx-get="/vehicles?filter=bus" hx-push-url="true" |
| Sidebar edit | EditingContext + Sidebar + SidebarContent | hx-get="/vehicles/123/edit" hx-target="#sidebar" |
| Auth | OIDC client lib + ProtectedRoute + SessionContext | Server-side session cookie + 401 redirect |
| i18n | react-i18next client-side | Server-side i18next (mature SSR support) |
| Import wizard | 9 files, 7+ useState, step state machine | Multi-step form with hx-post per step (server holds wizard state) |
Estimated LOC reduction: 6,400 → ~1,500-2,000 (templates + ~200 lines of vanilla JS for resize/drag-drop).
-
Sobek is a GraphQL API, not an HTML-rendering server. HTMX needs a server that returns HTML fragments. You'd need either:
- A thin BFF (Backend-for-Frontend) layer that queries Sobek's GraphQL and renders HTML — e.g., Express/Fastify with a template engine, or Go with
html/template - Or modify Sobek itself to serve HTML (unlikely if it's a shared API)
- A thin BFF (Backend-for-Frontend) layer that queries Sobek's GraphQL and renders HTML — e.g., Express/Fastify with a template engine, or Go with
-
Runtime theme switching — MUI's
ThemeProvidermakes this easy. With HTMX you'd use CSS custom properties (doable, arguably cleaner, but requires a rewrite of the theme system). -
The import wizard is the one genuinely complex piece. Multi-step form with client-side XML parsing (
fast-xml-parser), column mapping, deduplication. This could work as a server-side wizard, but the XML parsing/assembly logic would need to move server-side too. -
MUI component library — you lose the pre-built table, drawer, dialog, stepper components. For an admin tool, this matters less (plain HTML tables work fine). But you'd need to build or find replacements for: modal dialogs, responsive drawer, stepper.
- Resizable panels (~40 lines of vanilla JS either way)
- Drag-and-drop file upload (~30 lines either way)
- Runtime theme switching (CSS custom properties vs MUI ThemeProvider — similar complexity, different approach)
HTMX is a genuinely better fit for this use case — it's a CRUD admin panel with simple data flows. The current React setup has ~4x more code than necessary because React forces you to manage server state in the client.
But the migration isn't "swap React for HTMX." It's:
- Build a BFF server that queries Sobek and renders HTML templates
- Move sort/filter/paginate logic to the BFF (or add it to Sobek's GraphQL)
- Move auth from client-side OIDC to server-side session management
- Move XML import logic to the BFF
- Replace MUI components with plain HTML + CSS
- Keep ~200 lines of vanilla JS for resize/drag-drop
This is a rewrite, not a refactor. The question is whether the simplicity payoff justifies it. For a 2-table admin tool maintained by a small team? Probably yes — if you're comfortable with a BFF layer.
Browser (HTMX + ~200 LOC vanilla JS)
↓ simple GET/POST → receives HTML fragments
Express + Nunjucks templates
↓ graphql-request (reuse existing queries)
Sobek GraphQL API (unchanged)
What is the BFF (Backend-for-Frontend)? A thin Express server that:
- Receives plain HTTP requests from HTMX (
GET /vehicles?page=2&sort=name) - Queries Sobek's GraphQL with
graphql-request(same lib the React app uses) - Renders a Nunjucks template with the data
- Returns an HTML fragment that HTMX swaps into the page
The browser never sees GraphQL. It just asks for HTML and gets HTML.
- Express app with Nunjucks template engine
- Server-side OIDC via
openid-client(token in secure HttpOnly cookie) - Reuse existing GraphQL queries from
src/graphql/vehicles/queries/ GET /→ full page with layout, nav, empty table containerGET /vehicle-types→<tbody>fragment with sorted/paginated rows- HTMX attributes:
hx-get,hx-trigger="load",hx-swap="innerHTML",hx-push-url - Server-side sort/filter/paginate (trivial in JS:
Array.sort().slice()) - Debounced search:
hx-trigger="input changed delay:300ms"
GET /vehicle-types/:id/edit→ edit form HTML fragmentPOST /vehicle-types/:id→ save, return updated<tr>viahx-swap="outerHTML"- Vanilla JS resize handle (~40 lines, extracted from current
useResizableSidebar.ts)
- Same pattern as vehicle types — new route, new template, reuse table layout partial
GET /import→ step 1 form (file upload)POST /import/parse→ server parses XML (movefast-xml-parserlogic server-side), returns step 2 (column mapping)POST /import/review→ returns step 3 (review/confirm)POST /import/submit→ POSTs NeTEx to Sobek, returns result- Wizard state held in server session (express-session)
- CSS custom properties for theming (replace MUI ThemeProvider)
- Server-side i18n with
i18next(same lib, has Node.js support) - Responsive layout with CSS media queries + container queries
- Port Playwright e2e tests (they test DOM selectors, not React internals)
- Playwright e2e tests should port with minimal changes (same
data-testidattrs) - Compare feature parity with current React app
- Verify OIDC flow works with server-side session
- Test with Sobek running locally at
localhost:37999
BFF = Backend for Frontend. A thin server whose only job is to serve HTML for a specific UI. It sits between the browser and the "real" API (Sobek's GraphQL), translating structured data into rendered HTML fragments.
Why HTMX needs it: HTMX replaces client-side JS with server-rendered HTML partials. When the user clicks "page 2", HTMX sends GET /vehicles?page=2 and the server returns a <tbody> fragment. No JSON. No client-side rendering. The server does the rendering. Since Sobek only speaks GraphQL/JSON, you need something in front of it that can query the API and return HTML.
For Hathor, the BFF would be ~300-500 lines of Express routes + Nunjucks templates. It absorbs all the complexity currently spread across 12 React hooks and 5 context providers.
- When Should You Use Hypermedia? — HTMX.org essay on when hypermedia fits (CRUD, text-heavy, nested updates) vs when it doesn't (offline, complex client interdependencies)
- A SPA Alternative — HTMX.org's case for HTML-centric dev as a simpler alternative to SPA frameworks like React
- HTMX is the Future — Chris James argues HTMX offers a simpler path by returning to hypermedia-driven development with minimal JS
- Server-driven web apps with HTMX — LogRocket tutorial: CRUD app with HTMX + Express + Supabase, covers partial HTML responses and AJAX attributes
- Full Stack App with Express and HTMX — Step-by-step tutorial building an interactive app with Express backend and HTMX frontend
- Backends For Frontends — Sam Newman's canonical article on the BFF pattern: why you create separate server-side backends tailored to each client interface
src/pages/GenericDataViewPage.tsx— the core pattern you'd be replacingsrc/hooks/useDataViewTableLogic.ts— client-side logic that moves to serversrc/components/search/SearchContext.tsx— 197 lines that become 1 HTMX attributesrc/components/external-inputs/autosys/MultiImport.tsx— the hardest piece to migratesrc/data/vehicle-types/useVehicleTypes.ts— data fetching that the BFF absorbs