htmx 逆引きレシピ
htmxでログインを作るには?
htmxでログインを作ると、「失敗したら同じ場所にエラーを出す」「成功したら次の画面へ遷移する」を、 HTML属性+サーバの返し方だけでシンプルに実装できます。
このページでは、ログインフォームの最小構成から、 失敗時は結果だけ表示、成功時はHX-Redirectで遷移するパターンをまとめます。
※このデモは「表示されること優先」で、失敗時もHTTP 200でHTML断片を返す構成です(本番では4xxにする運用も多いので、最後に注意点も書きます)。
使用するhtmx属性
hx-post:ログイン情報をPOSTして、結果のHTMLを差し替えるhx-target:結果を表示する先の要素(CSSセレクタ)を指定(例:#LOGIN_RESULT)hx-swap:差し替え方を指定(例:innerHTML)hx-indicator:通信中表示(ローディング)を出す要素を指定hx-disabled-elt:通信中だけボタン等をdisabledにして連打を防止
利用シーン
- 会員ページ/管理画面に入る前の本人確認
- 未ログイン時はログインへ誘導し、ログイン後は元の画面へ戻す
htmxでログインを作る
ログインは「フォーム送信」なので、基本は hx-post でサーバに投げます。 返ってきた結果(エラー/成功メッセージ)を、hx-target で指定した場所に差し替えればOKです。
- 失敗時:フォームの上(または下)に、エラーHTMLだけを返して表示する。
- 成功時:セッションを確立し、HX-Redirect でダッシュボード等へ遷移する。
これだけで「画面全体を再読み込みしないログイン体験」が作れます。
HTML
<div class="DEMO">
<?php
// CSRFトークンが無ければ作る
if (empty($_SESSION['csrf'])) {
// 乱数からCSRFトークンを生成する
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
// テンプレートに渡すCSRFトークンを取り出す
$csrf = (string)$_SESSION['csrf'];
?>
<!-- 見出し -->
<h2>htmxでログイン(デモ)</h2>
<!-- 結果表示領域(エラー/成功メッセージ) -->
<div id="LOGIN_RESULT" class="FORM-RESULT" aria-live="polite"></div>
<!-- フォーム本体 -->
<form
id="LOGIN_FORM"
class="FORM"
method="post"
action="/htmx/chap1-screen/login/"
hx-post="/htmx/demo/_login.php"
hx-target="#LOGIN_RESULT"
hx-swap="innerHTML"
hx-indicator="#LOGIN_LOADING"
hx-disabled-elt="#LOGIN_SUBMIT"
>
<!-- ログインID -->
<label>
ログインID
<input type="text" name="login_id" autocomplete="username" required>
</label>
<!-- パスワード -->
<label>
パスワード
<input type="password" name="password" autocomplete="current-password" required>
</label>
<!-- CSRFトークン -->
<input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8') ?>">
<!-- 送信ボタン -->
<button id="LOGIN_SUBMIT" type="submit" class="BTN">
ログイン
</button>
<!-- ローディング表示 -->
<span id="LOGIN_LOADING" class="LOADING" aria-live="polite"></span>
</form>
<!-- デモ用の固定アカウント表示 -->
<p>
デモID:<strong>demodemo</strong><br>
デモPW:<strong>XLAnaKFCKFV3</strong>
</p>
</div>
PHP
<?php
// 厳格モードを有効化する
declare(strict_types=1);
// セッションを開始する
session_start();
// htmxリクエストかどうかを判定する関数
function isHtmx(): bool {
// HX-Requestヘッダがtrueかどうかを見る
return isset($_SERVER['HTTP_HX_REQUEST']) && $_SERVER['HTTP_HX_REQUEST'] === 'true';
}
// OK用のHTML断片を作る関数
function htmlResultOk(string $title, string $detail = ''): string {
// 詳細があればdivで足す
$detailHtml = $detail !== '' ? '<div>' . htmlspecialchars($detail, ENT_QUOTES, 'UTF-8') . '</div>' : '';
// OK用の箱を返す
return '<div class="FORM-RESULT is-ok"><strong>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</strong>' . $detailHtml . '</div>';
}
// NG用のHTML断片を作る関数
function htmlResultNg(string $title, array $errors = []): string {
// リスト用のHTMLを初期化する
$list = '';
// エラーがあれば<ul>を作る
if (!empty($errors)) {
// ul開始
$list .= '<ul>';
// エラーを1行ずつliにする
foreach ($errors as $e) {
// li追加
$list .= '<li>' . htmlspecialchars($e, ENT_QUOTES, 'UTF-8') . '</li>';
}
// ul終了
$list .= '</ul>';
}
// NG用の箱を返す
return '<div class="FORM-RESULT is-ng"><strong>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</strong>' . $list . '</div>';
}
// ログインIDを取り出す
$loginId = trim((string)($_POST['login_id'] ?? ''));
// パスワードを取り出す
$password = (string)($_POST['password'] ?? '');
// CSRFトークンを取り出す
$csrf = (string)($_POST['csrf'] ?? '');
// CSRFトークンが一致するか確認する
if (!isset($_SESSION['csrf']) || !hash_equals((string)$_SESSION['csrf'], $csrf)) {
// 403を返す
http_response_code(200);
// htmxなら断片、通常ならテキスト
echo isHtmx() ? htmlResultNg('不正なリクエストです。', ['ページを再読み込みして、もう一度お試しください。']) : 'Forbidden';
// 終了する
exit;
}
// 入力エラー配列を用意する
$errors = [];
// ログインIDが空ならエラー
if ($loginId === '') $errors[] = 'ログインIDを入力してください。';
// パスワードが空ならエラー
if ($password === '') $errors[] = 'パスワードを入力してください。';
// 入力エラーがあれば返す
if ($errors) {
// 422を返す
http_response_code(200);
// htmxなら断片、通常ならテキスト
echo isHtmx() ? htmlResultNg('入力内容を確認してください。', $errors) : 'Bad Request';
// 終了する
exit;
}
// ===== デモ用:固定ID/Password(ここだけで認証) =====
// デモ用の正しいログインIDを定義する
$validLoginId = 'demodemo';
// デモ用の正しいパスワードを定義する
$validPassword = 'XLAnaKFCKFV3';
// 認証結果を判定する
$ok = ($loginId === $validLoginId) && ($password === $validPassword);
// 認証に失敗したら返す
if (!$ok) {
// 401を返す
http_response_code(200);
// 失敗メッセージを返す(ユーザ存在有無はぼかす)
echo isHtmx() ? htmlResultNg('ログインに失敗しました。', ['ログインIDまたはパスワードが違います。']) : 'Unauthorized';
// 終了する
exit;
}
// ログイン成功時にセッションIDを再生成する(固定化対策)
session_regenerate_id(true);
// ログイン済みフラグを立てる
$_SESSION['is_login'] = true;
// ログインIDを保存する
$_SESSION['login_id'] = $loginId;
// リダイレクト先を決める
$redirectTo = '/htmx/demo/_login_after.php';
// htmxの場合の処理
if (isHtmx()) {
// htmx用のリダイレクトヘッダを返す
header('HX-Redirect: ' . $redirectTo);
// ついでにメッセージも返す(表示される前に遷移することが多い)
echo htmlResultOk('ログインしました。', 'ダッシュボードへ移動します。');
// 終了する
exit;
}
// 通常POSTの場合は303で遷移する
header('Location: ' . $redirectTo, true, 303);
// 終了する
exit;
// ===== 参考:DB認証版(コメントアウト) =====
/*
try {
$pdo = new PDO(
'mysql:host=localhost;dbname=app;charset=utf8mb4',
'dbuser',
'dbpass',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]
);
$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE login_id = :login_id LIMIT 1');
$stmt->execute([':login_id' => $loginId]);
$user = $stmt->fetch();
$ok = $user && password_verify($password, (string)$user['password_hash']);
if (!$ok) {
http_response_code(401);
echo isHtmx()
? htmlResultNg('ログインに失敗しました。', ['ログインIDまたはパスワードが違います。'])
: 'Unauthorized';
exit;
}
session_regenerate_id(true);
$_SESSION['is_login'] = true;
$_SESSION['uid'] = (int)$user['id'];
$redirectTo = '/dashboard/';
if (isHtmx()) {
header('HX-Redirect: ' . $redirectTo);
echo htmlResultOk('ログインしました。', 'ダッシュボードへ移動します。');
exit;
}
header('Location: ' . $redirectTo, true, 303);
exit;
} catch (Throwable $e) {
http_response_code(500);
echo isHtmx()
? htmlResultNg('サーバエラーです。', ['時間をおいて再度お試しください。'])
: 'Server Error';
exit;
}
*/
デモ
htmxでログイン(デモ)
デモID:demodemo
デモPW:XLAnaKFCKFV3
解説
1) フォームは「プログレッシブ強化」で組む
<form>には通常の method/action を持たせつつ、htmx時だけ hx-post で送る形にすると、 JS無しでも最低限動くフォームになります(保守もラク)。
- hx-post:ログイン処理(例:/htmx/demo/_login.php)へPOST
- hx-target:結果表示領域(例:#LOGIN_RESULT)に反映
- hx-swap="innerHTML":結果領域の中身だけ差し替え
- hx-indicator:通信中のローディング表示
- hx-disabled-elt:二重送信防止(送信ボタン無効化)
2) 失敗時は「結果だけ」をHTML断片で返す
ログイン失敗や入力エラーは、ページ全体を返さずに エラー表示用のHTML断片だけ返します。 こうすると、フォームはそのまま、結果だけが更新されます。
デモ優先:このページの実装では、失敗時も HTTP 200 で返しています(そのままswapされて確実に表示されるため)。
- 入力エラー: 「入力内容を確認してください」+エラー一覧を返す
- 認証失敗: 「ログインに失敗しました」+共通メッセージを返す(存在有無はぼかす)
3) 成功時はセッション確立 → HX-Redirectで遷移
ログイン成功時は、セッションIDを再生成してからログイン情報を保存し、 レスポンスヘッダ HX-Redirect を返して遷移させます。
- 成功時:session_regenerate_id(true)(セッション固定化対策)
- ログイン状態を $_SESSION に保存
- HX-Redirect: /dashboard を返す
ログイン後は「ヘッダーや権限、読み込むリソース」が変わりやすいので、フルリロード遷移(HX-Redirect)は相性が良いです。
4) 最低限ここは押さえる(本番の要点)
- CSRF対策:トークンを発行してフォームに埋め込む(サーバで照合)
- セッション固定化対策:ログイン成功時に session_regenerate_id(true)
- HTTPS前提:Cookieは Secure/HttpOnly/SameSite を検討
- 総当たり対策:失敗回数・レート制限・ログ監視
本番で「失敗は4xxで返したい」場合は、htmx側で4xxでも表示できるように調整(beforeSwap等)する運用が一般的です。
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール