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等)する運用が一般的です。

このページの著者

もちもちみかん(システムエンジニア)

社内SEとしてグループ企業向けの業務アプリを要件定義〜運用まで一気通貫で担当しています。

経験:Webアプリ/業務システム

得意:PHP・JavaScript・MySQL・CSS

個人実績:フォーム生成基盤クイズ学習プラットフォーム

詳しいプロフィールはこちら!  もちもちみかんのプロフィール

もちもちみかん0系くん
TOPへ

もちもちみかん.comとは


このサイトでは、コーディングがめんどうくさい人向けのお助けツールとして、フォームやCSSをノーコードで生成できる、
 もちもちみかん.forms
 もちもちみかん.css1
 もちもちみかん.css2
と言ったジェネレーターを用意してます。

また、このサイトを通じて、「もちもちみかん」のかわいさを普及したいとかんがえてます!