"We went all-in on htmx—and spent the next 6 months untangling the mess."
HTML-first development (htmx, Hotwire, Unpoly) promises a simpler web: no JavaScript frameworks, faster delivery, and less complexity. But when we embraced it for a large SaaS app, we discovered the hidden costs nobody talks about.
Here’s what happens when "just use HTML" meets real-world complexity.
1. The Siren Song of HTML-First
The Promise
✅ No more React/Vue bloat
✅ Faster iterations (change HTML, not JS)
✅ Progressive enhancement by default
The Reality We Hit
❌ "Simple" interactions become HTML soup
<!-- A "simple" htmx dropdown -->
<div
hx-get="/filters"
hx-target="#filters"
hx-trigger="click, mouseenter delay:300ms"
hx-swap="innerHTML"
hx-sync="this:replace"
hx-disinherit="*"
>
<!-- 10 more attributes later... -->
</div>
- Result: Unmaintainable templates with hidden dependencies.
2. The 5 Pain Points Nobody Warns About
1. Debugging Nightmares
Problem:
- No React DevTools-style inspection
- Errors like "HTMX: Swap failed" with zero context
Workaround:
document.addEventListener("htmx:beforeSwap", (e) => {
if (e.detail.xhr.status === 500) {
console.error("Server exploded:", e.detail.xhr.responseText);
}
});
2. Testing Headaches
Traditional:
// React Testing Library
expect(screen.getByText("Submit")).toBeDisabled();
HTML-First:
# Capybara + RSpec
assert_selector "button[disabled]", text: "Submit"
- Flakiness: Tests break on HTML structure changes (not logic).
3. State Management Chaos
Before (React):
const [filters, setFilters] = useState({});
After (htmx):
<input name="status" type="hidden" value="approved">
<input name="date" type="hidden" value="2025-01-01">
- Result: State spread across hidden fields, hard to sync.
4. The CSS Trap
Problem:
- UI logic leaks into CSS because HTML can’t handle it:
/* Show tooltip only when sibling input is invalid */
input:invalid + .tooltip {
display: block;
}
5. When You Actually Need JS
The Breaking Point:
<!-- Drag/drop with htmx = 🤯 -->
<div
hx-post="/items"
hx-trigger="drop"
hx-vals='js:{id: event.dataTransfer.getData("id")}'
>
<!-- 200 lines of custom JS anyway -->
</div>
3. When HTML-First Works (And When It Doesn’t)
Good Fit For:
✅ Admin dashboards
✅ Content-heavy sites (blogs, news)
✅ Prototypes
Avoid For:
🚫 Complex SPAs (e.g., Figma, Notion clones)
🚫 Apps needing fine-grained state
🚫 Teams married to React/Vue
4. Survival Strategies
1. The 80/20 Rule
- Use htmx for 80% of CRUD
- Escape to Stimulus/Alpine for 20% complex UI
2. Adopt a Hybrid Architecture
graph LR
A[Server-Rendered HTML] -->|htmx| B(Simple Interactions)
A -->|Stimulus| C(Complex Components)
C -->|JSON API| D(React for Heavy Lifting)
3. Enforce Conventions
- Banned pattern:
<!-- ❌ Attribute soup -->
<div hx-get="..." hx-target="..." hx-trigger="...">
- Approved pattern:
<!-- ✅ Encapsulated in Stimulus -->
<div data-controller="filter" data-filter-url="/filters">
5. The Verdict
HTML-first isn’t a silver bullet—it’s a tradeoff:
- Wins: Faster delivery, smaller bundles
- Costs: Hidden complexity, testing fragility
Our team’s rule:
"Use htmx until it hurts, then reach for the right tool—not the purest one."
"But Our App Is Different!"
Maybe it is. Try this:
- Build one page with htmx
- Add one complex feature
- Measure team velocity
Hit an HTML-first wall? Share your story below!
Top comments (0)