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)