DEV Community

Alex Aslam
Alex Aslam

Posted on

The Dark Side of HTML-First Development

"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>
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
});
Enter fullscreen mode Exit fullscreen mode

2. Testing Headaches

Traditional:

// React Testing Library
expect(screen.getByText("Submit")).toBeDisabled();
Enter fullscreen mode Exit fullscreen mode

HTML-First:

# Capybara + RSpec
assert_selector "button[disabled]", text: "Submit"
Enter fullscreen mode Exit fullscreen mode
  • Flakiness: Tests break on HTML structure changes (not logic).

3. State Management Chaos

Before (React):

const [filters, setFilters] = useState({});
Enter fullscreen mode Exit fullscreen mode

After (htmx):

<input name="status" type="hidden" value="approved">
<input name="date" type="hidden" value="2025-01-01">
Enter fullscreen mode Exit fullscreen mode
  • 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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

3. Enforce Conventions

  • Banned pattern:
  <!-- ❌ Attribute soup -->
  <div hx-get="..." hx-target="..." hx-trigger="...">
Enter fullscreen mode Exit fullscreen mode
  • Approved pattern:
  <!-- ✅ Encapsulated in Stimulus -->
  <div data-controller="filter" data-filter-url="/filters">
Enter fullscreen mode Exit fullscreen mode

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:

  1. Build one page with htmx
  2. Add one complex feature
  3. Measure team velocity

Hit an HTML-first wall? Share your story below!

Top comments (0)