htmx で管理画面 CRUD を 10 倍速に:一覧・検索・編集・バルク操作テンプレ
管理画面の開発、もっとシンプルにできないかと思ったことはありませんか。React や Vue.js で状態管理を組み立てるのは強力ですが、単純な CRUD 操作のためだけに大量のボイラープレートを書くのは正直大変です。
そこで注目したいのが htmx です。htmx を使えば、JavaScript をほとんど書かずに、HTML 属性だけで動的な管理画面を実装できます。この記事では、一覧表示・検索・編集・バルク操作といった管理画面の定番機能を、htmx でどれだけ高速に実装できるかを具体的なコードとともにご紹介します。
背景
管理画面開発の課題
従来の管理画面開発では、フロントエンドとバックエンドを完全に分離し、REST API や GraphQL を介してデータをやり取りする SPA(Single Page Application)構成が主流でした。
mermaidflowchart TB
browser["ブラウザ"]
spa["React/Vue.js SPA"]
api["REST API"]
db[("データベース")]
browser -->|初回ロード| spa
spa -->|JSON リクエスト| api
api -->|クエリ| db
api -->|JSON レスポンス| spa
spa -->|DOM 更新| browser
この構成には以下のような利点がありました。
- フロントエンドとバックエンドの完全な分離
- リッチな UI/UX の実現
- モバイルアプリとの API 共有
しかし、管理画面のような CRUD 中心のシンプルな画面 においては、以下のような問題が発生します。
| # | 課題 | 具体例 |
|---|---|---|
| 1 | ボイラープレートの増加 | 状態管理、API クライアント、型定義の重複 |
| 2 | バンドルサイズの肥大化 | React + Redux + Router で 300KB 超え |
| 3 | 開発速度の低下 | フロント・バックの二重実装が必要 |
| 4 | 保守コストの増加 | フレームワークのバージョンアップ対応 |
htmx が解決するもの
htmx は HTML 属性だけで AJAX リクエストを実行 できる軽量ライブラリ(約 14KB)です。サーバーから返された HTML フラグメントを直接 DOM に挿入するため、JavaScript での状態管理やテンプレートレンダリングが不要になります。
mermaidflowchart TB
browser["ブラウザ"]
html["HTML + htmx 属性"]
server["サーバー(Node.js/Express)"]
db[("データベース")]
browser -->|初回ロード| html
html -->|HTML リクエスト| server
server -->|クエリ| db
server -->|HTML フラグメント| html
html -->|DOM 差し替え| browser
htmx を使うことで、以下のメリットが得られます。
- JavaScript コードの大幅削減
- サーバーサイドで HTML を生成するシンプルな構成
- 段階的な導入が可能(既存の HTML に属性を追加するだけ)
課題
管理画面で必要な機能
一般的な管理画面では、以下の機能が必須です。
mermaidflowchart LR
list["一覧表示"]
search["検索・フィルター"]
edit["編集"]
bulk["バルク操作"]
list --> search
search --> edit
edit --> bulk
style list fill:#e1f5ff
style search fill:#fff4e1
style edit fill:#ffe1f5
style bulk fill:#e1ffe1
それぞれの機能には、以下のような課題があります。
| # | 機能 | 従来の実装の課題 |
|---|---|---|
| 1 | 一覧表示 | ページネーション、ソート、無限スクロールの実装が複雑 |
| 2 | 検索・フィルター | 入力のたびに API を叩く処理とデバウンス実装が必要 |
| 3 | 編集 | インライン編集時の状態管理が煩雑 |
| 4 | バルク操作 | チェックボックスの状態管理と一括処理の実装 |
SPA で実装した場合のコード量
例えば、React で一覧表示とページネーションを実装する場合、以下のようなコードが必要です。
- useState でページ状態を管理
- useEffect で API 呼び出し
- ページネーションコンポーネントの実装
- ローディング状態の管理
結果として、数百行のコード が必要になることも珍しくありません。
htmx ではこれらを HTML 属性のみ で実装でき、コード量を 10 分の 1 以下に削減できます。
解決策
htmx の基本的な使い方
htmx は、HTML 要素に特定の属性を追加するだけで AJAX リクエストを発行できます。
基本属性
主要な htmx 属性は以下の通りです。
| # | 属性 | 説明 | 例 |
|---|---|---|---|
| 1 | hx-get | GET リクエストを送信 | hx-get="/api/users" |
| 2 | hx-post | POST リクエストを送信 | hx-post="/api/users" |
| 3 | hx-target | レスポンスを挿入する要素 | hx-target="#result" |
| 4 | hx-swap | 挿入方法の指定 | hx-swap="innerHTML" |
| 5 | hx-trigger | トリガーイベント | hx-trigger="keyup changed delay:500ms" |
基本的な例
以下は、ボタンクリックでユーザー一覧を取得する最小限の例です。
html<!-- ボタン要素 -->
<button
hx-get="/api/users"
hx-target="#user-list"
hx-swap="innerHTML"
>
ユーザー一覧を読み込む
</button>
<!-- 結果を表示する領域 -->
<div id="user-list"></div>
このボタンをクリックすると、/api/users に GET リクエストが送信され、返された HTML が #user-list 要素の innerHTML として挿入されます。
サーバー側の実装
サーバー側では、HTML フラグメントを返すだけです。
javascript// Express での実装例
app.get('/api/users', async (req, res) => {
const users = await db.query(
'SELECT * FROM users LIMIT 10'
);
// HTML フラグメントを生成して返す
const html = users
.map(
(user) => `
<div class="user-item">
<span>${user.name}</span>
<span>${user.email}</span>
</div>
`
)
.join('');
res.send(html);
});
この構成により、JavaScript での状態管理やテンプレートレンダリングが不要になります。
htmx の設定と初期化
htmx を使い始めるには、CDN から読み込むか、npm でインストールします。
CDN での読み込み
html<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<title>管理画面</title>
<!-- htmx を CDN から読み込み -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<!-- ここにコンテンツ -->
</body>
</html>
npm でのインストール
bashyarn add htmx.org
javascript// JavaScript ファイルでインポート
import 'htmx.org';
htmx は script タグで読み込むだけで自動的に初期化され、hx-* 属性を持つ要素を検出して動作します。追加の設定は不要です。
具体例
ここからは、管理画面の各機能を htmx で実装する具体例をご紹介します。
1. 一覧表示とページネーション
HTML 側の実装
一覧表示では、テーブルとページネーションボタンを配置します。
html<!-- ユーザー一覧テーブル -->
<div id="user-table-container">
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>名前</th>
<th>メール</th>
<th>登録日</th>
<th>操作</th>
</tr>
</thead>
<tbody id="user-list">
<!-- サーバーから返される HTML がここに挿入される -->
</tbody>
</table>
</div>
初回ロード時にデータを取得する場合は、hx-trigger="load" を使います。
html<!-- ページ読み込み時に自動的にデータを取得 -->
<tbody
id="user-list"
hx-get="/api/users?page=1"
hx-trigger="load"
hx-swap="innerHTML"
>
<tr>
<td colspan="5">読み込み中...</td>
</tr>
</tbody>
ページネーションの実装
ページネーションボタンには、hx-get でページ番号を指定します。
html<!-- ページネーションコントロール -->
<div id="pagination" class="pagination">
<button
hx-get="/api/users?page=1"
hx-target="#user-list"
hx-swap="innerHTML"
>
1
</button>
<button
hx-get="/api/users?page=2"
hx-target="#user-list"
hx-swap="innerHTML"
>
2
</button>
<button
hx-get="/api/users?page=3"
hx-target="#user-list"
hx-swap="innerHTML"
>
3
</button>
</div>
サーバー側の実装(Express + TypeScript)
サーバー側では、ページ番号に応じたデータを取得し、HTML を生成します。
typescriptimport express, { Request, Response } from 'express';
import { Pool } from 'pg';
const app = express();
const db = new Pool({
host: 'localhost',
database: 'admin_db',
user: 'admin',
password: 'password',
});
typescript// ページあたりの件数
const ITEMS_PER_PAGE = 10;
// ユーザー一覧の型定義
interface User {
id: number;
name: string;
email: string;
created_at: Date;
}
typescript// ユーザー一覧 API
app.get(
'/api/users',
async (req: Request, res: Response) => {
// ページ番号を取得(デフォルトは 1)
const page = parseInt(req.query.page as string) || 1;
const offset = (page - 1) * ITEMS_PER_PAGE;
try {
// データベースからユーザーを取得
const result = await db.query<User>(
'SELECT id, name, email, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2',
[ITEMS_PER_PAGE, offset]
);
// HTML フラグメントを生成
const html = result.rows
.map(
(user) => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
<td>
<button
hx-get="/api/users/${user.id}/edit"
hx-target="#edit-modal"
hx-swap="innerHTML">
編集
</button>
</td>
</tr>
`
)
.join('');
res.send(html);
} catch (error) {
res
.status(500)
.send(
'<tr><td colspan="5">エラーが発生しました</td></tr>'
);
}
}
);
このコードでは、クエリパラメータから page を取得し、OFFSET を計算してデータベースから該当ページのデータを取得しています。返される HTML は <tr> 要素の集合で、#user-list の tbody に直接挿入されます。
2. 検索・フィルター機能
リアルタイム検索の実装
検索機能では、入力フィールドに hx-get と hx-trigger を組み合わせます。
html<!-- 検索フォーム -->
<div class="search-box">
<input
type="text"
name="q"
placeholder="名前またはメールで検索..."
hx-get="/api/users/search"
hx-target="#user-list"
hx-trigger="keyup changed delay:500ms"
hx-include="[name='status']"
autocomplete="off"
/>
</div>
hx-trigger="keyup changed delay:500ms" により、キー入力から 500ms 後に自動的にリクエストが送信されます。これにより、ユーザーが入力を完了するまで待機し、不要なリクエストを削減できます。
フィルター選択肢の追加
ステータスなどのフィルター条件も追加できます。
html<!-- ステータスフィルター -->
<select
name="status"
hx-get="/api/users/search"
hx-target="#user-list"
hx-trigger="change"
hx-include="[name='q']"
>
<option value="">すべて</option>
<option value="active">アクティブ</option>
<option value="inactive">非アクティブ</option>
</select>
hx-include="[name='q']" により、検索テキストボックスの値も一緒に送信されます。
サーバー側の検索実装
サーバー側では、クエリパラメータを受け取って検索を実行します。
typescript// 検索 API
app.get(
'/api/users/search',
async (req: Request, res: Response) => {
const searchQuery = (req.query.q as string) || '';
const status = (req.query.status as string) || '';
try {
// SQL クエリの構築
let sql =
'SELECT id, name, email, created_at, status FROM users WHERE 1=1';
const params: any[] = [];
let paramIndex = 1;
// 検索クエリの追加
if (searchQuery) {
sql += ` AND (name ILIKE $${paramIndex} OR email ILIKE $${paramIndex})`;
params.push(`%${searchQuery}%`);
paramIndex++;
}
// ステータスフィルターの追加
if (status) {
sql += ` AND status = $${paramIndex}`;
params.push(status);
paramIndex++;
}
sql += ' ORDER BY id LIMIT 10';
const result = await db.query<User>(sql, params);
// 結果が 0 件の場合
if (result.rows.length === 0) {
res.send(
'<tr><td colspan="5">該当するユーザーが見つかりませんでした</td></tr>'
);
return;
}
// HTML フラグメントを生成
const html = result.rows
.map(
(user) => `
<tr>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
<td>
<button
hx-get="/api/users/${user.id}/edit"
hx-target="#edit-modal"
hx-swap="innerHTML">
編集
</button>
</td>
</tr>
`
)
.join('');
res.send(html);
} catch (error) {
res
.status(500)
.send(
'<tr><td colspan="5">検索中にエラーが発生しました</td></tr>'
);
}
}
);
このコードでは、検索クエリとステータスの両方を考慮した動的な SQL を構築しています。パラメータ化されたクエリを使用することで、SQL インジェクション攻撃を防いでいます。
3. インライン編集機能
編集モードの切り替え
インライン編集では、表示モードと編集モードを切り替えます。
html<!-- 表示モード(初期状態) -->
<tr id="user-row-123">
<td>123</td>
<td id="user-name-123">山田太郎</td>
<td id="user-email-123">yamada@example.com</td>
<td>2024/01/15</td>
<td>
<button
hx-get="/api/users/123/edit-form"
hx-target="#user-row-123"
hx-swap="outerHTML"
>
編集
</button>
</td>
</tr>
編集ボタンをクリックすると、行全体が編集フォームに置き換わります。
編集フォームの返却
サーバー側では、編集フォームを含む <tr> を返します。
typescript// 編集フォーム取得 API
app.get(
'/api/users/:id/edit-form',
async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
try {
const result = await db.query<User>(
'SELECT id, name, email, created_at FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
res
.status(404)
.send(
'<tr><td colspan="5">ユーザーが見つかりません</td></tr>'
);
return;
}
const user = result.rows[0];
// 編集フォームを含む tr を返す
const html = `
<tr id="user-row-${user.id}">
<td>${user.id}</td>
<td>
<input type="text" name="name" value="${
user.name
}" class="form-control">
</td>
<td>
<input type="email" name="email" value="${
user.email
}" class="form-control">
</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
<td>
<button
hx-put="/api/users/${user.id}"
hx-include="closest tr"
hx-target="#user-row-${user.id}"
hx-swap="outerHTML">
保存
</button>
<button
hx-get="/api/users/${user.id}/cancel-edit"
hx-target="#user-row-${user.id}"
hx-swap="outerHTML">
キャンセル
</button>
</td>
</tr>
`;
res.send(html);
} catch (error) {
res
.status(500)
.send(
'<tr><td colspan="5">エラーが発生しました</td></tr>'
);
}
}
);
hx-include="closest tr" により、最も近い <tr> 要素内のすべての input 値が送信されます。
更新処理の実装
保存ボタンがクリックされると、PUT リクエストでデータを更新します。
typescript// ユーザー更新 API
app.put(
'/api/users/:id',
express.urlencoded({ extended: true }),
async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const { name, email } = req.body;
try {
// バリデーション
if (!name || !email) {
res
.status(400)
.send(
'<tr><td colspan="5">名前とメールアドレスは必須です</td></tr>'
);
return;
}
// データベース更新
await db.query(
'UPDATE users SET name = $1, email = $2 WHERE id = $3',
[name, email, userId]
);
// 更新後のデータを取得
const result = await db.query<User>(
'SELECT id, name, email, created_at FROM users WHERE id = $1',
[userId]
);
const user = result.rows[0];
// 表示モードの HTML を返す
const html = `
<tr id="user-row-${user.id}">
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
<td>
<button
hx-get="/api/users/${user.id}/edit-form"
hx-target="#user-row-${user.id}"
hx-swap="outerHTML">
編集
</button>
</td>
</tr>
`;
res.send(html);
} catch (error) {
res
.status(500)
.send(
'<tr><td colspan="5">更新中にエラーが発生しました</td></tr>'
);
}
}
);
更新が成功すると、編集フォームが表示モードに戻ります。キャンセルボタンの実装も同様に、表示モードの HTML を返すだけです。
4. バルク操作(一括削除・一括更新)
チェックボックスの実装
バルク操作では、各行にチェックボックスを配置します。
html<!-- テーブルヘッダー -->
<thead>
<tr>
<th>
<input
type="checkbox"
id="select-all"
onclick="document.querySelectorAll('.user-checkbox').forEach(cb => cb.checked = this.checked)"
/>
</th>
<th>ID</th>
<th>名前</th>
<th>メール</th>
<th>登録日</th>
</tr>
</thead>
各行のチェックボックスには、ユーザー ID を値として設定します。
html<!-- データ行(サーバーから返される HTML) -->
<tr>
<td>
<input
type="checkbox"
class="user-checkbox"
name="user_ids"
value="123"
/>
</td>
<td>123</td>
<td>山田太郎</td>
<td>yamada@example.com</td>
<td>2024/01/15</td>
</tr>
バルク操作ボタンの実装
選択された項目に対して一括操作を実行するボタンを配置します。
html<!-- バルク操作コントロール -->
<div class="bulk-actions">
<button
hx-delete="/api/users/bulk-delete"
hx-include="[name='user_ids']:checked"
hx-target="#user-list"
hx-confirm="選択したユーザーを削除してもよろしいですか?"
class="btn btn-danger"
>
選択したユーザーを削除
</button>
<button
hx-post="/api/users/bulk-activate"
hx-include="[name='user_ids']:checked"
hx-target="#user-list"
class="btn btn-success"
>
選択したユーザーを有効化
</button>
</div>
hx-include="[name='user_ids']:checked" により、チェックされたチェックボックスの値のみが送信されます。hx-confirm 属性により、実行前に確認ダイアログが表示されます。
サーバー側のバルク削除実装
サーバー側では、複数の ID を受け取って一括処理を実行します。
typescript// バルク削除 API
app.delete(
'/api/users/bulk-delete',
express.urlencoded({ extended: true }),
async (req: Request, res: Response) => {
// チェックボックスの値を配列として取得
let userIds = req.body.user_ids;
// 単一の値の場合は配列に変換
if (!Array.isArray(userIds)) {
userIds = userIds ? [userIds] : [];
}
// バリデーション
if (userIds.length === 0) {
res
.status(400)
.send(
'<tr><td colspan="5">削除するユーザーを選択してください</td></tr>'
);
return;
}
try {
// 数値の配列に変換
const ids = userIds.map((id) => parseInt(id));
// バルク削除を実行
await db.query(
'DELETE FROM users WHERE id = ANY($1)',
[ids]
);
// 削除後の一覧を取得して返す
const result = await db.query<User>(
'SELECT id, name, email, created_at FROM users ORDER BY id LIMIT 10'
);
const html = result.rows
.map(
(user) => `
<tr>
<td>
<input type="checkbox" class="user-checkbox" name="user_ids" value="${
user.id
}">
</td>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
</tr>
`
)
.join('');
res.send(html);
} catch (error) {
res
.status(500)
.send(
'<tr><td colspan="5">削除中にエラーが発生しました</td></tr>'
);
}
}
);
PostgreSQL の ANY 演算子を使用して、配列内の ID に一致するすべての行を削除しています。
バルク更新の実装
ステータスの一括更新も同様に実装できます。
typescript// バルク有効化 API
app.post(
'/api/users/bulk-activate',
express.urlencoded({ extended: true }),
async (req: Request, res: Response) => {
let userIds = req.body.user_ids;
if (!Array.isArray(userIds)) {
userIds = userIds ? [userIds] : [];
}
if (userIds.length === 0) {
res
.status(400)
.send(
'<tr><td colspan="5">有効化するユーザーを選択してください</td></tr>'
);
return;
}
try {
const ids = userIds.map((id) => parseInt(id));
// ステータスを一括更新
await db.query(
'UPDATE users SET status = $1 WHERE id = ANY($2)',
['active', ids]
);
// 更新後の一覧を取得して返す
const result = await db.query<User>(
'SELECT id, name, email, created_at, status FROM users ORDER BY id LIMIT 10'
);
const html = result.rows
.map(
(user) => `
<tr>
<td>
<input type="checkbox" class="user-checkbox" name="user_ids" value="${
user.id
}">
</td>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
<td>
<span class="badge ${
user.status === 'active'
? 'badge-success'
: 'badge-secondary'
}">
${
user.status === 'active'
? 'アクティブ'
: '非アクティブ'
}
</span>
</td>
</tr>
`
)
.join('');
res.send(html);
} catch (error) {
res
.status(500)
.send(
'<tr><td colspan="5">更新中にエラーが発生しました</td></tr>'
);
}
}
);
5. ローディング表示とエラーハンドリング
ローディングインジケーターの実装
htmx には、リクエスト中に自動的にクラスを追加する機能があります。
html<!-- ローディング表示用のスタイル -->
<style>
.htmx-request {
opacity: 0.5;
pointer-events: none;
}
.htmx-request::after {
content: '読み込み中...';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
padding: 10px 20px;
border: 1px solid #ccc;
border-radius: 4px;
}
</style>
htmx は、リクエスト中の要素に自動的に htmx-request クラスを追加します。追加の JavaScript は不要です。
カスタムローディング表示
特定の要素にローディング表示を出したい場合は、hx-indicator 属性を使用します。
html<!-- ローディングスピナー -->
<div
id="loading-spinner"
class="spinner"
style="display: none;"
>
<div class="spinner-border" role="status">
<span class="sr-only">読み込み中...</span>
</div>
</div>
<!-- ボタンにローディングインジケーターを指定 -->
<button
hx-get="/api/users?page=1"
hx-target="#user-list"
hx-indicator="#loading-spinner"
>
データを読み込む
</button>
css/* hx-indicator で指定された要素は、リクエスト中に自動的に表示される */
.htmx-request #loading-spinner {
display: block !important;
}
エラーハンドリング
エラー時には、htmx が自動的に htmx-swapping:abort イベントを発行します。これをリスナーで捕捉できます。
javascript// グローバルエラーハンドリング
document.body.addEventListener(
'htmx:responseError',
function (event) {
// エラーレスポンスの処理
const target = event.detail.target;
if (event.detail.xhr.status === 500) {
target.innerHTML =
'<div class="alert alert-danger">サーバーエラーが発生しました</div>';
} else if (event.detail.xhr.status === 404) {
target.innerHTML =
'<div class="alert alert-warning">データが見つかりませんでした</div>';
}
}
);
この JavaScript コードは、htmx が発行するイベントをリスナーで受け取り、ステータスコードに応じたエラーメッセージを表示します。
6. 完全な実装例
ここまでの内容を統合した、完全な管理画面の実装例です。
HTML 全体
html<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>ユーザー管理画面</title>
<!-- Bootstrap CSS -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<!-- htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
.htmx-request {
opacity: 0.6;
}
.search-box {
margin-bottom: 20px;
}
.bulk-actions {
margin-bottom: 15px;
}
</style>
</head>
<body>
<div class="container mt-5">
<h1 class="mb-4">ユーザー管理</h1>
<!-- 検索・フィルター -->
<div class="row search-box">
<div class="col-md-6">
<input
type="text"
name="q"
class="form-control"
placeholder="名前またはメールで検索..."
hx-get="/api/users/search"
hx-target="#user-list"
hx-trigger="keyup changed delay:500ms"
hx-include="[name='status']"
autocomplete="off"
/>
</div>
<div class="col-md-3">
<select
name="status"
class="form-select"
hx-get="/api/users/search"
hx-target="#user-list"
hx-trigger="change"
hx-include="[name='q']"
>
<option value="">すべて</option>
<option value="active">アクティブ</option>
<option value="inactive">非アクティブ</option>
</select>
</div>
</div>
<!-- バルク操作 -->
<div class="bulk-actions">
<button
hx-delete="/api/users/bulk-delete"
hx-include="[name='user_ids']:checked"
hx-target="#user-list"
hx-confirm="選択したユーザーを削除してもよろしいですか?"
class="btn btn-danger btn-sm"
>
選択を削除
</button>
<button
hx-post="/api/users/bulk-activate"
hx-include="[name='user_ids']:checked"
hx-target="#user-list"
class="btn btn-success btn-sm"
>
選択を有効化
</button>
</div>
<!-- ユーザー一覧テーブル -->
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>
<input
type="checkbox"
id="select-all"
onclick="document.querySelectorAll('.user-checkbox').forEach(cb => cb.checked = this.checked)"
/>
</th>
<th>ID</th>
<th>名前</th>
<th>メール</th>
<th>ステータス</th>
<th>登録日</th>
<th>操作</th>
</tr>
</thead>
<tbody
id="user-list"
hx-get="/api/users?page=1"
hx-trigger="load"
hx-swap="innerHTML"
>
<tr>
<td colspan="7" class="text-center">
読み込み中...
</td>
</tr>
</tbody>
</table>
</div>
<!-- ページネーション -->
<nav
id="pagination-container"
hx-get="/api/users/pagination?page=1"
hx-trigger="load"
hx-swap="innerHTML"
></nav>
</div>
<!-- エラーハンドリング -->
<script>
document.body.addEventListener(
'htmx:responseError',
function (event) {
const target = event.detail.target;
if (event.detail.xhr.status === 500) {
target.innerHTML =
'<tr><td colspan="7" class="text-center text-danger">サーバーエラーが発生しました</td></tr>';
}
}
);
</script>
</body>
</html>
この HTML ファイルには、検索、フィルター、バルク操作、ページネーションのすべての機能が含まれています。htmx の属性により、JavaScript をほとんど書かずに動的な操作が可能です。
サーバー側の完全な実装
typescriptimport express, { Request, Response } from 'express';
import { Pool } from 'pg';
const app = express();
const PORT = 3000;
// ミドルウェア
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(express.static('public'));
// データベース接続
const db = new Pool({
host: 'localhost',
database: 'admin_db',
user: 'admin',
password: 'password',
port: 5432,
});
const ITEMS_PER_PAGE = 10;
typescript// 型定義
interface User {
id: number;
name: string;
email: string;
status: 'active' | 'inactive';
created_at: Date;
}
// ユーザー行の HTML を生成するヘルパー関数
function renderUserRow(user: User): string {
const statusBadge =
user.status === 'active'
? '<span class="badge bg-success">アクティブ</span>'
: '<span class="badge bg-secondary">非アクティブ</span>';
return `
<tr id="user-row-${user.id}">
<td>
<input type="checkbox" class="user-checkbox" name="user_ids" value="${
user.id
}">
</td>
<td>${user.id}</td>
<td>${user.name}</td>
<td>${user.email}</td>
<td>${statusBadge}</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
<td>
<button
hx-get="/api/users/${user.id}/edit-form"
hx-target="#user-row-${user.id}"
hx-swap="outerHTML"
class="btn btn-sm btn-primary">
編集
</button>
</td>
</tr>
`;
}
typescript// ユーザー一覧取得
app.get(
'/api/users',
async (req: Request, res: Response) => {
const page = parseInt(req.query.page as string) || 1;
const offset = (page - 1) * ITEMS_PER_PAGE;
try {
const result = await db.query<User>(
'SELECT id, name, email, status, created_at FROM users ORDER BY id LIMIT $1 OFFSET $2',
[ITEMS_PER_PAGE, offset]
);
const html = result.rows.map(renderUserRow).join('');
res.send(html);
} catch (error) {
console.error(error);
res
.status(500)
.send(
'<tr><td colspan="7" class="text-center text-danger">エラーが発生しました</td></tr>'
);
}
}
);
typescript// 検索・フィルター
app.get(
'/api/users/search',
async (req: Request, res: Response) => {
const searchQuery = (req.query.q as string) || '';
const status = (req.query.status as string) || '';
try {
let sql =
'SELECT id, name, email, status, created_at FROM users WHERE 1=1';
const params: any[] = [];
let paramIndex = 1;
if (searchQuery) {
sql += ` AND (name ILIKE $${paramIndex} OR email ILIKE $${paramIndex})`;
params.push(`%${searchQuery}%`);
paramIndex++;
}
if (status) {
sql += ` AND status = $${paramIndex}`;
params.push(status);
paramIndex++;
}
sql += ` ORDER BY id LIMIT ${ITEMS_PER_PAGE}`;
const result = await db.query<User>(sql, params);
if (result.rows.length === 0) {
res.send(
'<tr><td colspan="7" class="text-center">該当するユーザーが見つかりませんでした</td></tr>'
);
return;
}
const html = result.rows.map(renderUserRow).join('');
res.send(html);
} catch (error) {
console.error(error);
res
.status(500)
.send(
'<tr><td colspan="7" class="text-center text-danger">検索中にエラーが発生しました</td></tr>'
);
}
}
);
typescript// 編集フォーム取得
app.get(
'/api/users/:id/edit-form',
async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
try {
const result = await db.query<User>(
'SELECT id, name, email, status, created_at FROM users WHERE id = $1',
[userId]
);
if (result.rows.length === 0) {
res
.status(404)
.send(
'<tr><td colspan="7" class="text-center">ユーザーが見つかりません</td></tr>'
);
return;
}
const user = result.rows[0];
const html = `
<tr id="user-row-${user.id}">
<td>
<input type="checkbox" class="user-checkbox" name="user_ids" value="${
user.id
}" disabled>
</td>
<td>${user.id}</td>
<td>
<input type="text" name="name" value="${
user.name
}" class="form-control form-control-sm">
</td>
<td>
<input type="email" name="email" value="${
user.email
}" class="form-control form-control-sm">
</td>
<td>
<select name="status" class="form-select form-select-sm">
<option value="active" ${
user.status === 'active' ? 'selected' : ''
}>アクティブ</option>
<option value="inactive" ${
user.status === 'inactive' ? 'selected' : ''
}>非アクティブ</option>
</select>
</td>
<td>${new Date(user.created_at).toLocaleDateString(
'ja-JP'
)}</td>
<td>
<button
hx-put="/api/users/${user.id}"
hx-include="closest tr"
hx-target="#user-row-${user.id}"
hx-swap="outerHTML"
class="btn btn-sm btn-success">
保存
</button>
<button
hx-get="/api/users/${user.id}/cancel-edit"
hx-target="#user-row-${user.id}"
hx-swap="outerHTML"
class="btn btn-sm btn-secondary">
キャンセル
</button>
</td>
</tr>
`;
res.send(html);
} catch (error) {
console.error(error);
res
.status(500)
.send(
'<tr><td colspan="7" class="text-center text-danger">エラーが発生しました</td></tr>'
);
}
}
);
typescript// ユーザー更新
app.put(
'/api/users/:id',
async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
const { name, email, status } = req.body;
try {
if (!name || !email) {
res
.status(400)
.send(
'<tr><td colspan="7" class="text-center text-danger">名前とメールアドレスは必須です</td></tr>'
);
return;
}
await db.query(
'UPDATE users SET name = $1, email = $2, status = $3 WHERE id = $4',
[name, email, status, userId]
);
const result = await db.query<User>(
'SELECT id, name, email, status, created_at FROM users WHERE id = $1',
[userId]
);
const html = renderUserRow(result.rows[0]);
res.send(html);
} catch (error) {
console.error(error);
res
.status(500)
.send(
'<tr><td colspan="7" class="text-center text-danger">更新中にエラーが発生しました</td></tr>'
);
}
}
);
typescript// 編集キャンセル
app.get(
'/api/users/:id/cancel-edit',
async (req: Request, res: Response) => {
const userId = parseInt(req.params.id);
try {
const result = await db.query<User>(
'SELECT id, name, email, status, created_at FROM users WHERE id = $1',
[userId]
);
const html = renderUserRow(result.rows[0]);
res.send(html);
} catch (error) {
console.error(error);
res
.status(500)
.send(
'<tr><td colspan="7" class="text-center text-danger">エラーが発生しました</td></tr>'
);
}
}
);
typescript// バルク削除
app.delete(
'/api/users/bulk-delete',
async (req: Request, res: Response) => {
let userIds = req.body.user_ids;
if (!Array.isArray(userIds)) {
userIds = userIds ? [userIds] : [];
}
if (userIds.length === 0) {
res
.status(400)
.send(
'<tr><td colspan="7" class="text-center text-warning">削除するユーザーを選択してください</td></tr>'
);
return;
}
try {
const ids = userIds.map((id) => parseInt(id));
await db.query(
'DELETE FROM users WHERE id = ANY($1)',
[ids]
);
const result = await db.query<User>(
'SELECT id, name, email, status, created_at FROM users ORDER BY id LIMIT $1',
[ITEMS_PER_PAGE]
);
const html = result.rows.map(renderUserRow).join('');
res.send(html);
} catch (error) {
console.error(error);
res
.status(500)
.send(
'<tr><td colspan="7" class="text-center text-danger">削除中にエラーが発生しました</td></tr>'
);
}
}
);
typescript// バルク有効化
app.post(
'/api/users/bulk-activate',
async (req: Request, res: Response) => {
let userIds = req.body.user_ids;
if (!Array.isArray(userIds)) {
userIds = userIds ? [userIds] : [];
}
if (userIds.length === 0) {
res
.status(400)
.send(
'<tr><td colspan="7" class="text-center text-warning">有効化するユーザーを選択してください</td></tr>'
);
return;
}
try {
const ids = userIds.map((id) => parseInt(id));
await db.query(
'UPDATE users SET status = $1 WHERE id = ANY($2)',
['active', ids]
);
const result = await db.query<User>(
'SELECT id, name, email, status, created_at FROM users ORDER BY id LIMIT $1',
[ITEMS_PER_PAGE]
);
const html = result.rows.map(renderUserRow).join('');
res.send(html);
} catch (error) {
console.error(error);
res
.status(500)
.send(
'<tr><td colspan="7" class="text-center text-danger">更新中にエラーが発生しました</td></tr>'
);
}
}
);
typescript// ページネーション HTML 生成
app.get(
'/api/users/pagination',
async (req: Request, res: Response) => {
try {
const countResult = await db.query(
'SELECT COUNT(*) as total FROM users'
);
const total = parseInt(countResult.rows[0].total);
const totalPages = Math.ceil(total / ITEMS_PER_PAGE);
let html = '<nav><ul class="pagination">';
for (let i = 1; i <= totalPages; i++) {
html += `
<li class="page-item">
<button
class="page-link"
hx-get="/api/users?page=${i}"
hx-target="#user-list"
hx-swap="innerHTML">
${i}
</button>
</li>
`;
}
html += '</ul></nav>';
res.send(html);
} catch (error) {
console.error(error);
res.status(500).send('');
}
}
);
typescript// サーバー起動
app.listen(PORT, () => {
console.log(
`Server is running on http://localhost:${PORT}`
);
});
このサーバー実装により、すべての CRUD 操作が HTML フラグメントの送受信だけで完結します。JSON API を作る必要がなく、フロントエンド側の状態管理も不要です。
まとめ
htmx を使った管理画面開発は、従来の SPA アプローチと比べて圧倒的にシンプルです。この記事でご紹介した内容をまとめます。
htmx の主なメリット
| # | メリット | 効果 |
|---|---|---|
| 1 | JavaScript コードの削減 | 数百行 → 数十行に削減可能 |
| 2 | バンドルサイズの削減 | 300KB → 14KB に削減 |
| 3 | 開発速度の向上 | HTML 属性だけで動的機能を実装 |
| 4 | 保守性の向上 | サーバー側で完結するシンプルな構成 |
実装した機能のおさらい
この記事では、以下の管理画面の定番機能を htmx で実装しました。
- 一覧表示:
hx-getとhx-trigger="load"による自動読み込み - ページネーション: ボタンに
hx-getを設定するだけで実装 - 検索機能:
hx-trigger="keyup changed delay:500ms"によるリアルタイム検索 - フィルター:
hx-includeによる複数パラメータの送信 - インライン編集:
hx-swap="outerHTML"による行の置き換え - バルク操作:
hx-include="[name='user_ids']:checked"によるチェックボックス連携
どんな場合に htmx が適しているか
htmx は以下のようなケースで特に効果を発揮します。
- 管理画面やダッシュボードなどの CRUD 中心のアプリケーション
- 既存のサーバーサイドレンダリングアプリに動的機能を追加したい場合
- JavaScript フレームワークの学習コストを避けたい場合
- バンドルサイズを最小限に抑えたい場合
逆に、以下のような場合は React や Vue.js などのフレームワークが適しています。
- 複雑な状態管理が必要な場合
- リアルタイム性が重要な場合(チャット、コラボレーションツールなど)
- モバイルアプリと API を共有する必要がある場合
次のステップ
htmx をさらに活用するには、以下の拡張機能やテクニックも検討してみてください。
- htmx 拡張:
hx-boostによるサイト全体の SPA 化 - アニメーション: CSS トランジションとの組み合わせ
- バリデーション:
hx-validateによる入力検証 - WebSocket 対応:
hx-wsによるリアルタイム通信
htmx は、モダンな Web 開発の新しい選択肢として、ますます注目を集めています。シンプルで高速な管理画面開発を実現したい方は、ぜひ試してみてください。
関連リンク
articlehtmx で管理画面 CRUD を 10 倍速に:一覧・検索・編集・バルク操作テンプレ
articlehtmx でページネーション最適化:履歴操作・スクロール保持・a11y 対応まで
articlehtmx 属性チートシート:hx-get/hx-post/hx-swap/hx-target 早見表【実例付き】
articlehtmx × Express/Node.js 高速セットアップ:テンプレ・部分テンプレ構成の定石
articlehtmx パフォーマンス実測:同等 UI を SPA/SSR/htmx で作った場合の応答時間比較
articlehtmx の設計思想を図解で理解する:HTML over the Wire とハイパーメディアの本質
articleGitHub Copilot Workspace 速理解:仕様 → タスク分解 →PR までの“自動開発”体験
articleMySQL InnoDB 内部構造入門:Buffer Pool/Undo/Redo を俯瞰
articleMotion(旧 Framer Motion)で学ぶ物理ベースアニメ:バネ定数・減衰・質量の直感入門
articleJavaScript Web Animations API:滑らかに動く UI を設計するための基本と実践
articleGitHub Actions コンテキスト辞典:github/env/runner/secrets の使い分け最速理解
articlehtmx で管理画面 CRUD を 10 倍速に:一覧・検索・編集・バルク操作テンプレ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来