---
id: frontend-htmx-hotwire
title: HTMX / Hotwire — Server-driven UI
category: Coding
status: draft
source_trust_level: B
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
tags: [frontend, htmx, hotwire, vibe-coding]
tech_stack: { language: "HTML / Server", applicable_to: ["Frontend"] }
applied_in: []
aliases: [HTMX, Hotwire, Turbo, Stimulus, server-driven UI, MPA renaissance, Phoenix LiveView]
---

# HTMX / Hotwire / Phoenix LiveView

> SPA 의 반발. **Server 가 HTML 보냄, JS 최소**. HTMX (any backend), Hotwire (Rails), Phoenix LiveView (Elixir). 작은 bundle + 빠른 dev.

## 📖 핵심 개념
- HTML over the wire: server → HTML fragment.
- AJAX without JS: HTMX attribute.
- Stateful server: WebSocket 으로 push.
- Less JS: 인터랙션 만 client.

## 💻 코드 패턴

### HTMX 기본
```html
<!-- Like button — server 가 새 HTML fragment -->
<button hx-post="/api/like" hx-target="#likes" hx-swap="innerHTML">
  Like
</button>
<span id="likes">42</span>

<!-- Server -->
<!-- POST /api/like -->
<!-- Response: <span id="likes">43</span> -->
```

→ JS 0. Server 가 HTML 반환 → DOM swap.

### Triggers
```html
<!-- Click (default) -->
<button hx-get="/load">Load</button>

<!-- Input -->
<input hx-post="/search" hx-trigger="keyup changed delay:500ms" hx-target="#results" />

<!-- Visible -->
<div hx-get="/load-more" hx-trigger="revealed">Loading...</div>

<!-- Every X seconds -->
<div hx-get="/status" hx-trigger="every 5s">...</div>

<!-- On load -->
<div hx-get="/init" hx-trigger="load"></div>
```

### Swap modes
```html
<button hx-post="/data" hx-swap="innerHTML">  <!-- default -->
<button hx-post="/data" hx-swap="outerHTML">  <!-- 자기 element 교체 -->
<button hx-post="/data" hx-swap="afterend">    <!-- after 추가 -->
<button hx-post="/data" hx-swap="beforeend">   <!-- 안 끝에 추가 -->
<button hx-post="/data" hx-swap="delete">       <!-- element 제거 -->
```

### Form
```html
<form hx-post="/users" hx-target="#user-list" hx-swap="afterbegin">
  <input name="email" type="email" required>
  <input name="name" required>
  <button>Add</button>
</form>

<ul id="user-list">
  <!-- 새 user 가 위에 추가 -->
</ul>

<!-- Server -->
<!-- POST /users → Response: <li>Alice (a@b.com)</li> -->
```

### Boost (link / form 자동 AJAX)
```html
<body hx-boost="true">
  <a href="/about">About</a>  <!-- AJAX, no full reload -->
  <form action="/login" method="post">  <!-- AJAX -->
    ...
  </form>
</body>
```

→ MPA 처럼 link / form. SPA-like UX.

### Indicator
```html
<button hx-post="/save" hx-indicator="#saving">Save</button>
<span id="saving" class="htmx-indicator">Saving...</span>

<style>
  .htmx-indicator { display: none; }
  .htmx-request .htmx-indicator { display: inline; }
</style>
```

→ Loading state 자동.

### OOB swap (다른 곳도 update)
```html
<!-- Server response -->
<div id="primary">Updated</div>
<div id="notification" hx-swap-oob="true">Save successful!</div>
```

→ 한 응답 가 여러 곳 update.

### Confirm
```html
<button hx-delete="/users/42" hx-confirm="Are you sure?" hx-target="#user-42" hx-swap="delete">
  Delete
</button>
```

### Server (any backend)
```ts
// Hono
app.post('/like', async (c) => {
  const postId = c.req.param('postId');
  const newCount = await incrementLikes(postId);
  return c.html(`<span id="likes">${newCount}</span>`);
});

app.post('/users', async (c) => {
  const formData = await c.req.formData();
  const user = await createUser(Object.fromEntries(formData));
  return c.html(`<li>${user.name} (${user.email})</li>`);
});
```

→ HTML fragment 반환.

### Hyperscript (HTMX 의 sister)
```html
<button _="on click toggle .open on next div">Toggle</button>
<div>Content</div>
```

→ JS-like inline language. 작은 인터랙션.

### Hotwire Turbo (Rails)
```html
<!-- turbo-frame — 부분 page reload -->
<turbo-frame id="user_list">
  <ul>
    <li>Alice</li>
    <li>Bob</li>
  </ul>
</turbo-frame>

<a href="/users/new" data-turbo-frame="user_list">Add</a>
<!-- → 이 link 가 frame 만 reload -->
```

```html
<!-- turbo-stream — push update -->
<turbo-stream action="append" target="messages">
  <template>
    <div>New message</div>
  </template>
</turbo-stream>
```

### Hotwire Stimulus
```html
<div data-controller="hello">
  <input data-hello-target="name" type="text">
  <button data-action="click->hello#greet">Greet</button>
  <span data-hello-target="output"></span>
</div>
```

```js
// hello_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
  static targets = ['name', 'output'];
  
  greet() {
    this.outputTarget.textContent = `Hello, ${this.nameTarget.value}!`;
  }
}
```

### Phoenix LiveView (Elixir)
```elixir
defmodule MyAppWeb.UserLive do
  use MyAppWeb, :live_view
  
  def mount(_params, _session, socket) do
    {:ok, assign(socket, users: list_users(), query: "")}
  end
  
  def handle_event("search", %{"q" => q}, socket) do
    users = search_users(q)
    {:noreply, assign(socket, users: users, query: q)}
  end
  
  def render(assigns) do
    ~H"""
    <input type="text" phx-keyup="search" phx-debounce="300" value={@query} />
    <ul>
      <%= for user <- @users do %>
        <li><%= user.name %></li>
      <% end %>
    </ul>
    """
  end
end
```

→ WebSocket + diff push. SPA UX + server logic.

### Use cases
```
HTMX:
✅ CRUD apps
✅ Admin dashboards
✅ Forms / wizards
✅ E-commerce (product list)
✅ Real-time updates (polling)

❌ Heavy interactive (game, drawing)
❌ Offline-first
❌ Mobile native
```

### When 가치
```
- Backend team 가 frontend 도 — JS 적게
- 빠른 dev cycle
- 작은 / medium app
- SEO critical
- Mobile slow network
- Server-side state important
```

### When NOT 가치
```
- Heavy client interaction (drag, drawing)
- Offline app
- Mobile native (RN / Flutter)
- 큰 / 복잡 UI state
```

### Bundle
```
HTMX:    ~14 KB (gzip)
Hotwire: ~50 KB
React:   ~45 KB
+ app code

→ HTMX = 가장 작음.
```

### Performance
```
HTMX:
- Server-side render — fast TTFB
- 작은 JS — 빠른 hydration X (no hydration)
- AJAX = 작은 response

→ Marketing site / blog / CRUD = 매우 빠름.
```

### Real-time (HTMX SSE)
```html
<div hx-ext="sse" sse-connect="/events" sse-swap="message">
  Waiting for messages...
</div>
```

```ts
// Server
app.get('/events', (c) => {
  return new Response(
    new ReadableStream({
      start(controller) {
        const send = (data: any) => {
          controller.enqueue(`data: <div>${data}</div>\n\n`);
        };
        // ...
      },
    }),
    { headers: { 'Content-Type': 'text/event-stream' } }
  );
});
```

### React + HTMX hybrid
```
일부 page = HTMX (form, CRUD).
일부 page = React (interactive).
같은 app.
```

### Test
```ts
// Playwright
test('like button increments', async ({ page }) => {
  await page.goto('/post/1');
  const button = page.getByText('Like');
  const counter = page.locator('#likes');
  
  await expect(counter).toHaveText('42');
  await button.click();
  await expect(counter).toHaveText('43');
});
```

→ E2E 가 자연 (HTML server).

### Pitfalls
```
1. Backend = template (Handlebars / EJS / 자체).
2. CSRF token 매 form.
3. Validation = server-side.
4. URL state — 명시적 hx-push-url.
5. Browser back button — 자동 X. Configure.
```

### Datastar (modern alternative)
```html
<div data-on-load="$count = 0">
  <button data-on-click="$count++">{{$count}}</button>
</div>
```

→ HTMX 의 modern 후속. SignalR-like reactivity.

### Build / deploy
```
Backend = template + routes.
Frontend = HTML + 작은 JS (HTMX).
Deploy = 일반 server.

→ Vercel / Netlify (static) X — server 필요.
```

## 🤔 의사결정 기준
| 상황 | 추천 |
|---|---|
| CRUD admin | HTMX |
| Rails app | Hotwire (built-in) |
| Phoenix / Elixir | LiveView |
| 작은 인터랙션 | HTMX + Stimulus / Hyperscript |
| Heavy SPA | React / Solid |
| Backend-heavy team | HTMX |

## ❌ 안티패턴
- **HTMX + 큰 client state**: 잘못된 선택. SPA.
- **Server template 없음**: HTML fragment 어떻게?
- **CSRF 없음**: form 위험.
- **모든 page 가 sse-connect**: server 부담.
- **Validation client only**: server 가 진실.
- **JS 부족 — 사용자 못 input**: progressive 검토.

## 🤖 LLM 활용 힌트
- HTMX = MPA renaissance.
- Server-side template + HTML fragment.
- Boost = SPA-like links / forms.
- 작은 / CRUD app 의 sweet spot.

## 🔗 관련 문서
- [[Frontend_Progressive_Enhancement]]
- [[Backend_Server_Components_Pattern]]
- [[Backend_Hono_Modern]]
