Why We Switched from React to HTMX in Production: A 200-Site Case Study
Architecting Lean Admin Interfaces: A Server-Driven Migration from Client-Side Bundles to Fragment-Based Rendering
Current Situation Analysis
Internal tooling and administrative panels are routinely architected as single-page applications (SPAs) by default. The industry assumption is that modern interactivity requires a heavy client-side runtime, virtual DOM reconciliation, and a dedicated build pipeline. In practice, most admin workloads are fundamentally CRUD-driven: data grids, form submissions, modal dialogs, and simple state toggles. When teams force these lightweight interactions into a React or Vue architecture, they inherit a disproportionate amount of complexity.
The problem is rarely recognized until operational friction becomes measurable. Bundle sizes creep upward as new dependencies are added to solve minor UX gaps. Build times stretch, slowing down CI/CD feedback loops. New engineers face steep onboarding curves because they must understand routing, state synchronization, hydration boundaries, and component lifecycles before shipping a simple form. Tooling churn compounds the issue: teams cycle through state management libraries, router versions, and form handlers, each migration introducing regression risk and technical debt.
Data from a 200-site production deployment across a multi-tenant CMS platform illustrates the hidden cost of this mismatch. Over a three-year period, the React-based admin panel accumulated:
- A gzipped client bundle of approximately 800 KB, split across three vendor chunks and three lazy-loaded routes
- First Contentful Paint to Largest Contentful Paint (LCP) latency ranging from 3.0s to 3.5s from a single Istanbul edge node
- Production build times averaging 90 seconds, with development hot-rebuilds taking 8 seconds
- A 10–14 day onboarding window before new hires could independently ship self-contained features
- Approximately 42,000 lines of code, heavily duplicated across client-side validation, optimistic update logic, and state mirroring
None of these metrics are catastrophic in isolation. Stacked together, they transform routine maintenance into a high-friction operation. The architectural overhead of an SPA was being applied to a workload that required none of its core capabilities. The result was a system where every minor feature request carried disproportionate implementation cost, and the team began deprioritizing low-impact improvements due to regression anxiety.
WOW Moment: Key Findings
After a six-month, route-by-route migration to a server-driven, fragment-based rendering model using HTMX, the operational metrics shifted dramatically. The migration was executed across 200+ production deployments without downtime, using parallel routing and incremental deprecation of client-side routes.
| Approach | Client Bundle (Gzipped) | LCP (p75, Istanbul) | TTI (p75, Istanbul) | Prod Build Time | Total LOC | Backend p95 Latency |
|---|---|---|---|---|---|---|
| React SPA (Before) | 800 KB | 3.2s | 4.1s | 90s | ~42,000 | 180ms |
| HTMX Server-Driven (After) | ~50 KB | 1.1s | 1.2s | 6s | ~28,000 | 220ms |
The 94% reduction in client payload eliminates the hydration tax and JavaScript parsing overhead. LCP and TTI converge because the browser receives a fully rendered document fragment instead of a shell that requires client-side execution. The 33% drop in total lines of code stems from removing dual validation layers, client-side state mirrors, and optimistic update scaffolding.
The trade-off is explicit: backend response latency increased by 22ms. Server-side rendering shifts computational work from the client to the backend. This is not a regression; it is a deliberate architectural reallocation. Client CPU cycles and network payload size are exchanged for server-side rendering time and cache hit rates. For admin panels with low concurrent user counts (typically 1–5 editors per tenant), this trade-off is highly favorable. Server resources are predictable, cacheable, and horizontally scalable. Client-side bundle bloat is not.
This finding enables a fundamental shift in how internal tools are built. Teams stop optimizing for client-side render performance and start optimizing for fragment cache efficiency, backend response consistency, and HTML contract stability. The complexity moves to a layer where it is easier to monitor, test, and scale.
Core Solution
The migration relies on three architectural principles: parallel routing, server-owned state, and fragment-based DOM updates. Implementation follows a strict incremental path to avoid big-bang rewrites.
Step 1: Establish Parallel Routing
Both stacks coexist during the transition. Legacy SPA routes are prefixed (e.g., /admin/legacy/*), while new server-rendered routes use the standard path (e.g., /admin/*). A shared session cookie ensures authentication state persists across both environments. Users can navigate between React and HTMX routes without re-authentication or context loss.
Step 2: Implement Fragment-Based Server Rendering
The backend no longer returns JSON for UI interactions. Instead, it returns HTML fragments tailored to specific DOM targets. The client uses declarative attributes to trigger requests and swap responses.
Form Submission with Inline Validation
<form hx-post="/api/inventory/items"
hx-target="#validation-feedback"
hx-swap="innerHTML"
hx-indicator="#submit-spinner">
<input type="text" name="sku_code" required>
<textarea name="description"></textarea>
<button type="submit" id="submit-spinner">Commit</button>
<div id="validation-feedback"></div>
</form>
Server-side TypeScript handler (Express/Fastify pattern):
app.post('/api/inventory/items', async (req, res) => {
const { sku_code, description } = req.body;
const validationErrors = validateInventoryItem(sku_code, description);
if (validationErrors.length > 0) {
return res.status(400).send(`
<div class="error-list">
${validationErrors.map(e => `<p class="text-red-600">${e}</p>`).join('')}
</div>
<form hx-post="/api/inventory/items" hx-target="#validation-feedback" hx-swap="innerHTML">
<input type="text" name="sku_code" value="${escapeHtml(sku_code)}" required>
<textarea name="description">${escapeHtml(description)}</textarea>
<button type="submit">Retry</button>
</form>
`);
}
await saveInventoryItem({ sku_code, description });
return res.status(200).send(`
<div class="success-toast">Item committed successfully.</div>
<form hx-post="/api/inventory/items" hx-target="#validation-feedback" hx-swap="innerHTML">
<input type="text" name="sku_code" placeholder="New SKU" required>
<textarea name="description" placeholder="Description"></textarea>
<button type="submit">Commit</button>
</form>
`);
});
The server acts as the single source of truth. Validation logic lives once, on the backend. The client never mirrors schema rules. This eliminates the drift that typically occurs when client-side and server-side validation diverge after schema changes.
Step 3: Infinite Scroll via Sentinel Pattern
Long data grids use a lightweight pagination sentinel instead of virtual scrolling libraries or manual IntersectionObserver implementations.
<section id="report-feed">
<div class="entry">...</div>
<div hx-get="/api/reports/next?cursor=last_id"
hx-trigger="revealed"
hx-swap="beforeend"
class="pagination-sentinel">
Fetching next batch...
</div>
</section>
When the sentinel enters the viewport, HTMX triggers a GET request. The server returns the next batch of entries plus a new sentinel with an updated cursor. The beforeend swap appends content without replacing existing DOM nodes, preserving scroll position and event listeners.
Step 4: Modal Dialogs as Server Fragments
Modals are not client components. They are HTML fragments returned on demand.
<button hx-get="/api/users/104/profile"
hx-target="#overlay-container"
hx-trigger="click"
class="trigger-overlay">
View Details
</button>
<div id="overlay-container"></div>
The server responds with a fully structured <dialog> element, including form fields, action buttons, and initial state. Closing the modal triggers a POST that returns an empty fragment, replacing the container's content. Dialog state lives on the server, eliminating client-side modal managers and z-index conflicts.
Architecture Rationale
- Server-Owned State: Eliminates dual validation, reduces client bundle, and simplifies debugging. The browser receives exactly what it needs to render.
- Incremental Deprecation: Routes are migrated feature-by-feature, starting with read-only lists and progressing to complex forms. After each deployment, legacy JavaScript is deleted. Bundle size reduction is visible in real-time, maintaining team momentum.
- Cache-First Rendering: Server-side partials are cached using Redis or in-memory stores. Cache invalidation is tied to data mutations, not client navigation. This absorbs the 22ms backend latency increase while maintaining sub-100ms response times for repeated interactions.
Pitfall Guide
1. Replicating Client-Side State Management
Explanation: Teams transitioning from React often attempt to rebuild Redux or Zustand patterns using HTMX triggers and custom JavaScript. This defeats the purpose of server-driven rendering and reintroduces synchronization complexity.
Fix: Let the server own state. Use HTMX attributes to request fresh fragments after mutations. If client-side caching is required, use localStorage or sessionStorage for non-critical UI preferences, not business data.
2. Ignoring Server-Side Caching Strategy
Explanation: Shifting rendering to the backend increases CPU load. Without a caching layer, p95 latency will degrade under concurrent admin usage. Fix: Implement fragment-level caching. Cache rendered HTML partials keyed by route parameters and data version hashes. Invalidate caches on write operations. Monitor cache hit rates and adjust TTLs based on data volatility.
3. Misusing Modals as Client Components
Explanation: Developers accustomed to React often try to manage modal open/close state in JavaScript, leading to race conditions, backdrop conflicts, and focus management bugs.
Fix: Treat modals as server fragments. The trigger requests markup, the server returns a <dialog> with open attribute, and closing returns an empty replacement. Use hx-target and hx-swap exclusively. Handle focus restoration via htmx:afterSwap events if necessary.
4. Testing Implementation Details Instead of HTML Contracts
Explanation: Dropping React Testing Library without replacing it with backend integration tests leaves the system unverified. Unit tests for UI state become irrelevant when state lives on the server. Fix: Shift testing to the backend. Write integration tests that hit endpoints, assert HTTP status codes, and validate returned HTML structure. Use DOM parsing libraries to verify fragment content. Total test count will decrease, but coverage will improve because you are testing actual user interactions, not component render cycles.
5. Assuming Offline-First Compatibility
Explanation: HTMX relies on network round-trips for every interaction. Admin panels requiring offline editing or flaky network tolerance will break. Fix: Acknowledge the network dependency upfront. If offline support is mandatory, isolate those specific workflows in a separate SPA or use service workers with IndexedDB synchronization. Do not force HTMX into offline-first architectures.
6. Over-Engineering Optimistic Updates
Explanation: Attempting to replicate React's optimistic UI patterns with HTMX leads to complex hx-swap-oob chains and rollback logic that is harder to maintain than simple server-wait patterns.
Fix: Accept slight latency for correctness. Use hx-indicator for visual feedback. Reserve optimistic updates for high-frequency, low-risk interactions (e.g., toggling a boolean flag). For critical operations, wait for server confirmation before updating the DOM.
7. Neglecting Backend Team HTML Proficiency
Explanation: Backend developers accustomed to returning JSON often struggle with semantic HTML, accessibility attributes, and HTMX-specific markup. This creates friction and inconsistent fragment quality. Fix: Establish HTML fragment standards. Provide shared templates, linting rules for accessibility, and documentation on HTMX attributes. Cross-train backend engineers on server-side rendering patterns. Treat HTML as a first-class API contract.
Production Bundle
Action Checklist
- Audit existing SPA routes and categorize by complexity (read-only, form-heavy, interactive)
- Implement parallel routing with shared session authentication
- Migrate read-only list views first to establish fragment caching patterns
- Replace client-side validation with server-rendered error fragments
- Implement Redis or in-memory caching for frequently accessed partials
- Shift testing strategy to backend integration tests asserting HTML structure
- Delete legacy JavaScript and React routes incrementally after each successful migration
- Monitor backend p95 latency and cache hit rates post-deployment
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|---|---|---|
| CRUD admin panel with 1–5 concurrent users | Server-driven HTMX | Low interactivity, high data volume, predictable workload | Reduces client hosting costs, increases backend compute slightly |
| Customer-facing storefront with rich animations | Client-side SPA (React/Vue) | Requires smooth transitions, offline tolerance, complex state | Higher CDN bandwidth, lower backend render load |
| Hybrid platform with both admin and public routes | Parallel routing architecture | Isolates complexity, allows workload-specific optimization | Moderate infrastructure overhead, maximizes developer velocity |
| Real-time collaborative editing | Dedicated WebSocket/CRDT solution | HTMX request/response model cannot handle sub-100ms sync | Highest infrastructure cost, necessary for correctness |
Configuration Template
Vite Configuration (CSS Bundling Only)
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss()],
build: {
outDir: 'public/assets',
rollupOptions: {
input: 'src/styles/app.css',
output: {
assetFileNames: 'css/[name].[hash][extname]'
}
}
}
});
HTMX Initialization & Global Config
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
<script>
document.body.addEventListener('htmx:configRequest', (event) => {
event.detail.headers['X-Requested-With'] = 'XMLHttpRequest';
event.detail.headers['Accept'] = 'text/html, application/xhtml+xml';
});
document.body.addEventListener('htmx:afterOnLoad', (event) => {
if (event.detail.xhr.status === 401) {
window.location.href = '/auth/login';
}
});
</script>
Server-Side Fragment Cache Middleware (Node/Express)
import { Router } from 'express';
import { getCache, setCache } from './cache';
export const fragmentRouter = Router();
fragmentRouter.get('/api/reports/next', async (req, res) => {
const cursor = req.query.cursor as string;
const cacheKey = `fragment:reports:${cursor}`;
const cached = await getCache(cacheKey);
if (cached) return res.send(cached);
const data = await fetchReportBatch(cursor);
const html = renderReportFragment(data);
await setCache(cacheKey, html, 300); // 5-minute TTL
res.send(html);
});
Quick Start Guide
- Install HTMX: Add the HTMX script tag to your base layout or bundle it via your existing CSS/JS pipeline. No build step is required for the library itself.
- Create a Parallel Route: Set up a new endpoint that returns HTML fragments instead of JSON. Prefix it with
/admin/next/to coexist with your existing SPA. - Migrate One List View: Replace a static data grid with an HTMX-powered table. Use
hx-getfor initial load andhx-trigger="revealed"for pagination. Verify cache hit rates. - Validate & Deploy: Run backend integration tests against the new endpoint. Confirm fragment structure matches expectations. Deploy and monitor p95 latency.
- Iterate: Repeat for form submissions and modals. Delete legacy JavaScript after each successful migration. Track bundle size reduction in your CI pipeline.
The shift from client-side state management to server-driven fragment rendering is not a rejection of modern JavaScript. It is a recalibration of architectural responsibility. When the workload is CRUD-heavy and concurrency is low, the browser's native HTML parsing capabilities outperform virtual DOM reconciliation. The metrics from 200+ production deployments confirm that deleting unnecessary client-side complexity yields faster interfaces, shorter build cycles, and more maintainable codebases. Choose the architecture that matches the workload, not the trend.
Mid-Year Sale — Unlock Full Article
Base plan from just $4.99/mo or $49/yr
Sign in to read the full article and unlock all tutorials.
Sign In / Register — Start Free Trial7-day free trial · Cancel anytime · 30-day money-back
