この記事の要点
• htmxはHTML属性だけでAJAX・WebSocket・CSSトランジションを実現
• htmx 2.0でgzipサイズが14KB→10KBに削減
• JavaScriptを書かずにインタラクティブなWebアプリを構築可能
• サーバーサイド言語を問わず使えるシンプルなアプローチ
htmxとは
htmxは、HTMLの属性だけでAJAXリクエスト、CSSトランジション、WebSocketなどを実現するライブラリです。JavaScriptを書かずにインタラクティブなWebアプリケーションを構築できます。
htmx 2.0の新機能
サイズの削減
- htmx 1.x: 14KB (gzip)
- htmx 2.0: 10KB (gzip)
新しい属性
<!-- hx-disabled-elt: リクエスト中に要素を無効化 -->
<button hx-post="/api/submit"
hx-disabled-elt="this">
送信
</button>
<!-- hx-on: イベントハンドラをより簡潔に -->
<div hx-on:htmx:after-request="alert('完了!')">
...
</div>
<!-- hx-inherit: 継承の制御 -->
<div hx-boost="true">
<a href="/page" hx-inherit="false">
ブーストを無効化
</a>
</div>
基本的な使い方
AJAXリクエスト
<!-- GETリクエスト -->
<button hx-get="/api/users" hx-target="#user-list">
ユーザー一覧を取得
</button>
<div id="user-list"></div>
<!-- POSTリクエスト -->
<form hx-post="/api/users" hx-target="#result">
<input name="name" placeholder="名前">
<input name="email" placeholder="メール">
<button type="submit">登録</button>
</form>
<div id="result"></div>
トリガーのカスタマイズ
<!-- 入力時に検索(デバウンス付き) -->
<input type="search"
name="q"
hx-get="/search"
hx-trigger="input changed delay:300ms"
hx-target="#search-results">
<!-- スクロールで追加読み込み -->
<div hx-get="/api/posts?page=2"
hx-trigger="revealed"
hx-swap="afterend">
Loading...
</div>
スワップ戦略
<!-- 内部を置換(デフォルト) -->
<div hx-get="/content" hx-swap="innerHTML">
<!-- 要素全体を置換 -->
<div hx-get="/content" hx-swap="outerHTML">
<!-- 末尾に追加 -->
<div hx-get="/content" hx-swap="beforeend">
<!-- トランジション付き -->
<div hx-get="/content" hx-swap="innerHTML transition:true">
サーバーサイドとの連携
Expressの例
app.get('/api/users', (req, res) => {
const users = getUsers();
// HTMLを直接返す
res.send(`
<ul>
${users.map(u => `<li>${u.name}</li>`).join('')}
</ul>
`);
});
// HX-Trigger ヘッダーでイベントを発火
app.post('/api/users', (req, res) => {
const user = createUser(req.body);
res.setHeader('HX-Trigger', 'userCreated');
res.send(`<p>ユーザー ${user.name} を作成しました</p>`);
});
Go (Echo) の例
e.POST("/api/todos", func(c echo.Context) error {
todo := createTodo(c.FormValue("title"))
c.Response().Header().Set("HX-Trigger", "todoAdded")
return c.HTML(200, fmt.Sprintf(
`<li>%s</li>`, todo.Title,
))
})
拡張機能
WebSocket
<script src="https://unpkg.com/htmx.org/dist/ext/ws.js"></script>
<div hx-ext="ws" ws-connect="/chat">
<div id="messages"></div>
<form ws-send>
<input name="message">
<button>送信</button>
</form>
</div>
Server-Sent Events
<script src="https://unpkg.com/htmx.org/dist/ext/sse.js"></script>
<div hx-ext="sse" sse-connect="/events">
<div sse-swap="message">
イベントを待機中...
</div>
</div>
View Transitions API対応
<meta name="htmx-config" content='{"globalViewTransitions": true}'>
<style>
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.3s;
}
</style>
いつhtmxを使うべきか
向いているケース:
- サーバーサイドレンダリング中心のアプリ
- シンプルなインタラクション
- プログレッシブエンハンスメント
- JavaScriptを最小限にしたい
向かないケース:
- 複雑なクライアント状態管理
- オフライン対応が必要
- リッチなUIコンポーネント
背景 - なぜhtmxが再評価されているのか
2010年代後半のWeb開発はReact、Vue、Angularといった大規模JavaScriptフレームワーク中心でした。しかしSPA (Single Page Application) の複雑性、バンドルサイズ肥大化、ハイドレーションコスト、SEO問題などが蓄積し、2023年頃から「HTML over the wire」と呼ばれる揺り戻しが起きています。
htmx、Hotwire (Turbo + Stimulus)、Livewire、Unpoly などがこの流れの代表で、サーバーサイドでHTMLを生成してブラウザが差分を適用する 方式です。htmx 2.0は2024年6月にリリースされ、IE11/古いブラウザサポートを削除、モダンブラウザに絞ることでコードを整理しました。
htmx 2.0の主要な変更点
- IE11 サポート廃止 - XHR、querySelectorAll、Promiseがネイティブで使える前提に。
- WebSocket / SSE を拡張機能化 - コアから外部拡張 (htmx-ext-ws, htmx-ext-sse) に移動。
hx-on属性の強化 - イベントごとに分けたhx-on:click,hx-on::after-swapなどを公式採用。- View Transitions API の公式統合 -
transition:trueでネイティブ遷移アニメーション。 - プラグインAPIの整理 - 拡張作者向けの hooks が整備された。
詳細な機能解説
hx-swap戦略とOOB (Out of Band)
サーバーから返されたHTMLを、メインターゲット以外の場所にも反映させたい場合 hx-swap-oob を使います。
<!-- メインの差し替え -->
<div id="cart-items">...</div>
<!-- サーバーからのレスポンスに含める -->
<span id="cart-count" hx-swap-oob="true">3</span>
サーバー側はカートに商品を追加したあと、カート一覧に加えて #cart-count も同じレスポンスで返せば、2箇所が同時に更新されます。
hx-boost でプログレッシブエンハンスメント
通常のリンクやフォームをhtmx経由に昇格し、ページ全体の再読込を防ぐ機能です。
<body hx-boost="true">
<nav>
<a href="/about">About</a>
<a href="/contact">Contact</a>
</nav>
</body>
JSが無効でも通常のリンクとして機能するため、アクセシビリティを保ったまま SPA 風の体験を実現できます。
リクエストライフサイクルイベント
htmxは豊富なイベントを発火します。これを使えば複雑なUIロジックも属性だけで書けます。
<button
hx-post="/api/like"
hx-on:htmx:before-request="this.disabled = true"
hx-on:htmx:after-request="this.disabled = false"
hx-on:htmx:response-error="showToast('エラーが発生しました')">
いいね
</button>
Indicator (ローディング表示)
<style>
.htmx-indicator { display: none; }
.htmx-request .htmx-indicator { display: inline; }
</style>
<button hx-get="/slow" hx-indicator="#spinner">
読み込み
<img id="spinner" class="htmx-indicator" src="/spinner.svg" alt="">
</button>
hx-validate と HTML標準バリデーション
<form hx-post="/signup" hx-validate="true">
<input type="email" name="email" required>
<input type="password" name="password" minlength="8" required>
<button>登録</button>
</form>
送信前にブラウザ標準バリデーションが走り、エラーがあればリクエストは発行されません。
実践サンプル - インクリメンタル検索とライブ結果
サーバー (Node.js + Express) 側:
import express from "express";
const app = express();
const items = ["apple", "banana", "cherry", "durian", "elderberry"];
app.get("/search", (req, res) => {
const q = (req.query.q || "").toLowerCase();
const results = items.filter((item) => item.includes(q));
res.send(`
<ul>
${results.map((r) => `<li>${r}</li>`).join("")}
</ul>
`);
});
app.listen(3000);
クライアント側:
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body>
<input
type="search"
name="q"
placeholder="検索..."
hx-get="/search"
hx-trigger="input changed delay:300ms, search"
hx-target="#results"
hx-indicator="#loading">
<span id="loading" class="htmx-indicator">検索中...</span>
<div id="results"></div>
</body>
</html>
わずかこれだけで、デバウンス付きインクリメンタル検索が実装できます。
実践サンプル - 無限スクロール
<div id="posts">
<!-- 初期投稿 -->
</div>
<div
hx-get="/posts?page=2"
hx-trigger="revealed"
hx-swap="outerHTML">
<span class="htmx-indicator">読み込み中...</span>
</div>
サーバーは次ページのHTMLと、そのさらに次のページを読み込むトリガーdivを返すだけ。
htmx 1.x vs 2.x 比較
| 項目 | htmx 1.x | htmx 2.x |
|---|---|---|
| ブラウザサポート | IE11+ | Evergreen のみ |
| サイズ (gzip) | 約14KB | 約10KB |
| WebSocket/SSE | コア内蔵 | 拡張機能 |
| hx-on 構文 | hx-on="..." 一括 | hx-on:event 分離 |
| View Transitions | 実験的 | 正式サポート |
| 設定メタタグ | htmx-config | 同じ (拡張) |
他技術との比較
| 技術 | 方式 | 学習コスト | 適した用途 |
|---|---|---|---|
| htmx 2.x | HTML属性 + サーバーHTML | 低 | CRUD、管理画面、ブログ |
| Hotwire (Turbo) | Rails中心、Turbo Frame/Stream | 中 | Rails アプリ |
| Alpine.js | 宣言的JS | 低 | クライアント状態のみ |
| React / Next.js | SPA / RSC | 高 | 大規模SPA |
| Svelte / SvelteKit | コンパイル型 | 中 | 中〜大規模 |
| Livewire (Laravel) | PHP状態をサーバー保持 | 中 | Laravel アプリ |
ベストプラクティス
- サーバーはHTMLの断片を返す: JSONを返してクライアントでテンプレート化しない。HTMLそのものが API。
HX-Requestヘッダで出しわけ: htmxリクエスト時は断片、通常アクセス時はフルページを返すと初回表示にも対応できる。- 状態はURLに: タブやフィルタの状態は
hx-push-url="true"でURLに同期し、ブラウザバックに対応。 - CSRF対策: フォームや非GETリクエストに
hx-headers='{"X-CSRF-Token":"..."}'を付与。 - コンポーネント化: サーバー側のテンプレートエンジン (Jinja, ERB, Blade, Pug) のパーシャルを活用して再利用。
- アクセシビリティ:
aria-live領域を利用して動的更新をスクリーンリーダーに伝える。 - CSPフレンドリーに: インラインイベントに近い
hx-onを多用するとCSPが緩くなる。重要な箇所はdata-*+ カスタムスクリプトで。
注意点・落とし穴
- XSS: サーバーが生成するHTMLに未エスケープのユーザー入力が含まれると即XSS。テンプレートエンジンの自動エスケープを有効に。
- オフライン非対応: 全てサーバーに依存するので、オフラインファーストアプリには不向き。
- リアクティブ状態: 入力値がリアルタイムで相互に影響するような複雑UIは Alpine.js と併用するか、別のツールを検討。
- 大量のDOM更新: 1万行のテーブルを差し替えるといった処理は仮想DOMより重くなる場合がある。
- hx-onの注意: 属性値にJSを書くため大きなロジックには向かない。関数呼び出しに留める。
導入手順
- 既存のサーバーサイドレンダリングアプリに
<script src="https://unpkg.com/htmx.org@2.0.0">を追加。 - リンクやフォームを少しずつ
hx-get/hx-postに置き換える。 - サーバー側に
if request.headers.get("HX-Request")分岐を追加し、断片とフルページを出し分ける。 - エラー処理、ローディング表示、バリデーションの共通ヘルパーを整備。
- 必要に応じて拡張 (htmx-ext-ws, htmx-ext-response-targets, htmx-ext-morphdom-swap) を追加。
- E2Eテスト (Playwright など) でリグレッションを自動化。
パフォーマンスの目安
| 項目 | SPA (React) | htmx 2.x |
|---|---|---|
| 初回JS | 100〜500KB | 10KB |
| 初回TTFB | サーバー軽め | サーバーが重め |
| 初回FCP | やや遅 | 速い |
| 画面遷移 | 高速 (クライアント) | 高速 (断片更新) |
| サーバー負荷 | 低 (API) | 中 (HTML生成) |
実測ではサーバー側テンプレート生成+ネットワーク往復で100〜200ms以下に収まれば、ユーザー体験はSPAと遜色ありません。
FAQ
Q1. htmxはJavaScriptを書かなくていいの? A. 大部分は不要です。ただしClient側で完結する細かい状態管理 (モーダル開閉、トグル) には Alpine.js などを併用するのがおすすめです。
Q2. SEOは問題ない?
A. サーバーが完全なHTMLを返すため、SEOはSPAより有利です。hx-boost + hx-push-url でURL構造も保たれます。
Q3. React / Vue から移行すべき? A. 全てを移行する必要はありません。管理画面、フォーム中心のサブアプリなど、向いている部分だけを移行すると効果的です。
Q4. TypeScript は使えますか? A. htmxはHTMLベースなのでTypeScript型は不要ですが、サーバー側 (Hono, Elysia, Express + TS) を型安全にすればAPI境界で型を効かせられます。
Q5. 大規模チームでも使える? A. 使えます。ただしサーバー側テンプレートの規約 (パーシャル命名、レスポンスヘッダ運用) をチームで統一することが重要です。
まとめ
htmx 2.0は、HTMLの拡張だけでインタラクティブなWebアプリケーションを構築できる軽量ライブラリです。サーバーサイドレンダリングとの相性が良く、シンプルなアプリケーションでは複雑なJavaScriptフレームワークの代替となります。
- SPAより軽量でSEOに強い
- サーバー中心の開発でフロントの複雑さを減らせる
- プログレッシブエンハンスメントが自然にできる
- ただしリッチクライアントには不向き
「HTMLを返せば動く」というシンプルさは、中小規模サービスや管理画面において圧倒的な生産性をもたらします。2026年のWeb開発では SPA か htmx か、という択一ではなく「適材適所で両方を使う」スタンスが主流になっていくでしょう。
補足: 実例 - TODOアプリ完成版
以下は htmx 2.x と Hono (Cloudflare Workers / Node) で作る TODO アプリの最小実装です。フォーム送信、楽観更新、削除、空状態メッセージまでを含みます。
// server.ts
import { Hono } from "hono";
import { html } from "hono/html";
type Todo = { id: number; title: string; done: boolean };
const todos: Todo[] = [];
let nextId = 1;
const app = new Hono();
const layout = (children: string) => html`
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>htmx TODO</title>
<script src="https://unpkg.com/htmx.org@2.0.0"></script>
</head>
<body>
<h1>TODO</h1>
${children}
</body>
</html>
`;
const todoItem = (t: Todo) => `
<li id="todo-${t.id}">
<label>
<input type="checkbox" ${t.done ? "checked" : ""}
hx-post="/todos/${t.id}/toggle"
hx-target="#todo-${t.id}"
hx-swap="outerHTML">
${t.done ? `<s>${t.title}</s>` : t.title}
</label>
<button
hx-delete="/todos/${t.id}"
hx-target="#todo-${t.id}"
hx-swap="outerHTML">削除</button>
</li>
`;
const todoList = () => `
<ul id="todo-list">
${todos.length === 0 ? "<li>まだTODOがありません</li>" : todos.map(todoItem).join("")}
</ul>
`;
app.get("/", (c) =>
c.html(layout(`
<form hx-post="/todos" hx-target="#todo-list" hx-swap="outerHTML"
hx-on::after-request="this.reset()">
<input name="title" required placeholder="やること">
<button>追加</button>
</form>
${todoList()}
`)),
);
app.post("/todos", async (c) => {
const body = await c.req.parseBody();
todos.push({ id: nextId++, title: String(body.title), done: false });
return c.html(todoList());
});
app.post("/todos/:id/toggle", (c) => {
const id = Number(c.req.param("id"));
const t = todos.find((t) => t.id === id);
if (!t) return c.notFound();
t.done = !t.done;
return c.html(todoItem(t));
});
app.delete("/todos/:id", (c) => {
const id = Number(c.req.param("id"));
const idx = todos.findIndex((t) => t.id === id);
if (idx === -1) return c.notFound();
todos.splice(idx, 1);
return c.html(""); // empty replacement removes the <li>
});
export default app;
このサンプルから分かるように、「どのURLを叩き、何を返すか」という設計だけで動的UIが成立するのがhtmxの強みです。状態管理ライブラリもビルドステップも不要で、テストは HTTP のリクエスト/レスポンスとして書けます。