htmx 逆引きレシピ
ログイン処理を扱うには?
公開日:
最終更新日:
管理画面では「未ログインならログインへ」「保存後は詳細へ」「権限不足なら403へ」など、遷移の分岐が必ず発生します。
この判断をフロント側で頑張ると複雑になりがちですが、htmxならサーバー主導でスッキリ扱えます。
このページでは、HX-Redirect / HX-Location を使い、ログイン誘導・保存後遷移・権限エラーを1つのデモにまとめます。
「準SPAのまま自然に遷移する」実装パターンとして、そのまま業務UIに流用できます。
使用するhtmx属性
hx-boost:リンク/フォーム送信をAJAX化して“準SPA”にする(既存SSRに後付けしやすい)hx-target:差し替え先を指定(このデモでは#REDIRECT_APPを更新する)hx-select:レスポンスから必要な部分だけ抜き出す(#REDIRECT_APPだけ反映)hx-swap:差し替え方を指定(outerHTMLで箱ごと置換し、入れ子崩れを防ぐ)HX-Redirect:サーバー側の判断で別ページへ遷移させる(未ログイン/権限不足の誘導に強い)HX-Location:遷移先を指示して“次の画面を読み直す”(保存後→詳細などに最適)
利用シーン
- 「未ログインならログインへ」:編集/保存を押した瞬間にログイン画面へ誘導したい
- 「保存後に詳細ページへ」:作成/更新のあと“詳細へ遷移”して確認させたい
- 「権限不足なら403画面へ」:admin専用の操作を、権限がないユーザーに見せないようにしたい
リダイレクト/ログイン誘導を扱う
このデモは、遷移の分岐をサーバーで判断し、htmxのヘッダでクライアントへ指示します。
結果として、JSを書かずに「ログイン誘導」「保存後遷移」「403表示」を自然な導線で実装できます。
PHP(メイン画面/_redirect_auth.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// htmxリクエストか判定する
function isHx(): bool {
// HX-Requestヘッダを見る
return (($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true');
}
// ログイン状態を判定する
$isLogin = (bool)($_SESSION['is_login'] ?? false);
// ロールを取得する(user/admin)
$role = (string)($_SESSION['role'] ?? 'user');
// 初期データが無ければ作る
if (!isset($_SESSION['records']) || !is_array($_SESSION['records'])) {
// 初期一覧を作る
$_SESSION['records'] = [
['id' => 801, 'title' => '申請:端末貸与', 'owner' => 'tanaka', 'status' => 'OPEN'],
['id' => 802, 'title' => '申請:権限変更', 'owner' => 'sato', 'status' => 'PENDING'],
['id' => 803, 'title' => '申請:アカウント追加', 'owner' => 'suzuki', 'status' => 'DONE'],
];
}
// 一覧を取り出す
$records = (array)$_SESSION['records'];
?>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>リダイレクト/ログイン誘導 | htmx</title>
<link rel="stylesheet" media="print" href="/css/C32.table.css">
<link rel="stylesheet" href="/css/C01.destyle.css?2026-01-03">
<link rel="stylesheet" href="/css/C11.vars.css?2026-01-03">
<link rel="stylesheet" href="/css/C12.str.css?2026-01-03">
<link rel="stylesheet" href="/css/C13.num.css?2026-01-03">
<link rel="stylesheet" href="/css/C14.color.css?2026-01-03">
<link rel="stylesheet" href="/css/C21.base.css?2026-01-03">
<link rel="stylesheet" href="/css/C22.form.css?2026-01-03">
<link rel="stylesheet" href="/css/C23.parts.css?2026-01-03">
<link rel="stylesheet" href="/css/C31.calendar.css?2026-01-03">
<link rel="stylesheet" href="/css/C32.table.css?2026-01-03">
<!-- htmx -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body class="P1rm W800 CENTER">
<!--
この箱だけを“準SPA”化する
- hx-boost:リンク/フォームをAJAX化
- hx-target/hx-select:この箱だけ差し替え
- hx-swap:outerHTMLで入れ子事故を防ぐ
-->
<section
id="REDIRECT_APP"
class="SECTION"
hx-boost="true"
hx-target="#REDIRECT_APP"
hx-select="#REDIRECT_APP"
hx-swap="outerHTML"
>
<h1 class="FS2rm">リダイレクト/ログイン誘導を扱うには?</h1>
<p class="HTMX-NOTE">
未ログインならログインへ/保存後は詳細へ/権限不足なら403へ…を1つのデモでまとめます。<br>
遷移の判断はサーバー側で行い、<code>HX-Redirect</code> と <code>HX-Location</code> で誘導します。
</p>
<!-- 状態表示 -->
<div class="CARD">
<strong>ログイン:</strong><?= $isLogin ? 'YES' : 'NO' ?>
<strong>権限:</strong><?= h($role) ?>
</div>
<!-- ログイン/ログアウト -->
<div class="FORM__FOOT MB2rm">
<?php if (!$isLogin): ?>
<a class="BTN is-ok" href="/htmx/demo/_redirect_auth_login.php?next=/htmx/demo/_redirect_auth.php">
ログインへ
</a>
<?php else: ?>
<a class="BTN is-ghost" href="/htmx/demo/_redirect_auth_logout.php">
ログアウト
</a>
<?php endif; ?>
<a class="BTN" href="/htmx/demo/_redirect_auth_admin.php">
管理者ページ(admin専用)
</a>
</div>
<!-- 保存フォーム(ログイン必須) -->
<section class="SECTION MB32">
<h2 class="FS24">保存 → 詳細へ(ログイン必須)</h2>
<p class="HTMX-NOTE">
保存に成功したら、<code>HX-Location</code> で詳細ページへ遷移します(準SPAのまま)。<br>
未ログインの場合は <code>HX-Redirect</code> でログイン画面へ飛ばします。
</p>
<form class="FORM" method="post" action="/htmx/demo/_redirect_auth_save.php">
<label>
タイトル
<input type="text" name="title" value="" placeholder="例:申請:メーリングリスト追加">
</label>
<label>
担当
<input type="text" name="owner" value="" placeholder="例:tanaka">
</label>
<label>
ステータス
<select name="status">
<option value="OPEN">OPEN</option>
<option value="PENDING">PENDING</option>
<option value="DONE">DONE</option>
</select>
</label>
<button class="BTN is-ok MT16" type="submit">保存して詳細へ</button>
</form>
</section>
<!-- 一覧(詳細はログイン必須) -->
<section class="SECTION MB32">
<h2 class="FS24">一覧(詳細はログイン必須)</h2>
<div class="TABLE_WRAPPER">
<table class="TABLE">
<thead>
<tr>
<th class="W15pc">ID</th>
<th>タイトル</th>
<th class="W15pc">担当</th>
<th class="W15pc">ステータス</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($records as $r): ?>
<tr>
<td><?= h((string)$r['id']) ?></td>
<td><?= h((string)$r['title']) ?></td>
<td><?= h((string)$r['owner']) ?></td>
<td><span class="BADGE"><?= h((string)$r['status']) ?></span></td>
<td>
<a class="BTN" href="/htmx/demo/_redirect_auth_detail.php?id=<?= h((string)$r['id']) ?>">
詳細を見る(ログイン必須)
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
</section>
</body>
</html>
PHP(ログイン画面/_redirect_auth_login.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// next(ログイン後に戻る先)を受け取る
$next = (string)($_GET['next'] ?? '/htmx/demo/_redirect_auth.php');
// 空ならデフォルトへ
if ($next === '') $next = '/htmx/demo/_redirect_auth.php';
?>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ログイン | リダイレクト</title>
<!-- htmx -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<section
id="REDIRECT_APP"
class="SECTION"
hx-boost="true"
hx-target="#REDIRECT_APP"
hx-select="#REDIRECT_APP"
hx-swap="outerHTML"
>
<h1>ログイン</h1>
<p class="HTMX-NOTE">
ログイン成功後は、<code>next</code> に戻します。<br>
(未ログインでアクセスしたページを“そのまま再開”できます)
</p>
<form class="FORM" method="post" action="/htmx/demo/_redirect_auth_login_do.php">
<input type="hidden" name="next" value="<?= h($next) ?>">
<label>
ユーザー名(ダミー)
<input type="text" name="user" value="mikan">
</label>
<label>
権限(デモ用)
<select name="role">
<option value="user">user</option>
<option value="admin">admin</option>
</select>
</label>
<button class="BTN is-ok" type="submit">ログイン</button>
<a class="BTN is-ghost" href="/htmx/demo/_redirect_auth.php">戻る</a>
</form>
</section>
</body>
</html>
PHP(ログイン処理 → 次へ/_redirect_auth_login_do.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// htmxリクエストか判定する
function isHx(): bool {
// HX-Requestヘッダを見る
return (($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true');
}
// next(ログイン後に戻る先)を受け取る
$next = (string)($_POST['next'] ?? '/htmx/demo/_redirect_auth.php');
// ロールを受け取る
$role = (string)($_POST['role'] ?? 'user');
// ロールが空ならuser
if ($role === '') $role = 'user';
// ログイン済みにする
$_SESSION['is_login'] = true;
// ロールを保存する
$_SESSION['role'] = $role;
// セッション固定化対策(デモでもやっておく)
session_regenerate_id(true);
// htmxならHX-Redirectで遷移させる
if (isHx()) {
// リダイレクト先を指示する
header('HX-Redirect: ' . $next);
// 終了する
exit;
}
// 通常リクエストならLocationで遷移させる
header('Location: ' . $next);
// 終了する
exit;
PHP(ログアウト/_redirect_auth_logout.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// ログイン状態を消す
unset($_SESSION['is_login']);
// ロールも消す
unset($_SESSION['role']);
// もとの画面へ戻す
header('Location: /htmx/demo/_redirect_auth.php');
// 終了する
exit;
PHP(保存 → 詳細へ/_redirect_auth_save.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// htmxリクエストか判定する
function isHx(): bool {
// HX-Requestヘッダを見る
return (($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true');
}
// ログイン状態を判定する
$isLogin = (bool)($_SESSION['is_login'] ?? false);
// 未ログインならログインへ飛ばす
if (!$isLogin) {
// ログイン後はメインへ戻す
$next = '/htmx/demo/_redirect_auth.php';
// htmxならHX-Redirectで遷移
if (isHx()) {
// ログイン画面へ誘導する
header('HX-Redirect: /htmx/demo/_redirect_auth_login.php?next=' . rawurlencode($next));
// 終了する
exit;
}
// 通常ならLocationで遷移
header('Location: /htmx/demo/_redirect_auth_login.php?next=' . rawurlencode($next));
// 終了する
exit;
}
// タイトルを受け取る
$title = (string)($_POST['title'] ?? '');
// 担当を受け取る
$owner = (string)($_POST['owner'] ?? '');
// ステータスを受け取る
$status = (string)($_POST['status'] ?? 'OPEN');
// 空対策
if ($title === '') $title = '申請:新規作成';
// 空対策
if ($owner === '') $owner = 'tanaka';
// IDを作る(簡易)
$newId = random_int(900, 99999);
// 一覧を取り出す
$records = (array)($_SESSION['records'] ?? []);
// 先頭に追加する
array_unshift($records, [
'id' => $newId,
'title' => $title,
'owner' => $owner,
'status' => $status,
]);
// セッションに保存する
$_SESSION['records'] = $records;
// 詳細URLを作る
$detailUrl = '/htmx/demo/_redirect_auth_detail.php?id=' . $newId;
// htmxならHX-Locationで“準SPA遷移”する
if (isHx()) {
// HX-Locationで次の画面を読み直させる(#REDIRECT_APPだけ差し替える)
header('HX-Location: ' . json_encode([
'path' => $detailUrl,
'target' => '#REDIRECT_APP',
'select' => '#REDIRECT_APP',
'swap' => 'outerHTML',
], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
// 終了する
exit;
}
// 通常ならLocationで遷移する
header('Location: ' . $detailUrl);
// 終了する
exit;
PHP(詳細:未ログインならログインへ/_redirect_auth_detail.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
// htmxリクエストか判定する
function isHx(): bool {
// HX-Requestヘッダを見る
return (($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true');
}
// ログイン状態を判定する
$isLogin = (bool)($_SESSION['is_login'] ?? false);
// idを受け取る
$id = (int)($_GET['id'] ?? 0);
// 未ログインならログインへ飛ばす
if (!$isLogin) {
// ログイン後に戻るURLを作る
$next = '/htmx/demo/_redirect_auth_detail.php?id=' . $id;
// htmxならHX-Redirect
if (isHx()) {
// ログイン画面へ誘導する
header('HX-Redirect: /htmx/demo/_redirect_auth_login.php?next=' . rawurlencode($next));
// 終了する
exit;
}
// 通常ならLocation
header('Location: /htmx/demo/_redirect_auth_login.php?next=' . rawurlencode($next));
// 終了する
exit;
}
// 一覧を取り出す
$records = (array)($_SESSION['records'] ?? []);
// 対象レコードを探す
$found = null;
// 1件ずつ探す
foreach ($records as $r) {
// idが一致したら採用する
if ((int)($r['id'] ?? 0) === $id) {
$found = $r;
break;
}
}
?>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>詳細 | リダイレクト</title>
<!-- htmx -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<section
id="REDIRECT_APP"
class="SECTION"
hx-boost="true"
hx-target="#REDIRECT_APP"
hx-select="#REDIRECT_APP"
hx-swap="outerHTML"
>
<h1>詳細ページ</h1>
<?php if ($found === null): ?>
<p class="HTMX-NOTE">対象データが見つかりません。</p>
<?php else: ?>
<ul class="HTMX-LIST">
<li><strong>ID:</strong><?= h((string)$found['id']) ?></li>
<li><strong>タイトル:</strong><?= h((string)$found['title']) ?></li>
<li><strong>担当:</strong><?= h((string)$found['owner']) ?></li>
<li><strong>ステータス:</strong><?= h((string)$found['status']) ?></li>
</ul>
<?php endif; ?>
<div class="FORM__FOOT">
<a class="BTN is-ghost" href="/htmx/demo/_redirect_auth.php">一覧へ戻る</a>
</div>
</section>
</body>
</html>
PHP(admin専用 → 権限不足なら403へ/(admin専用 → 権限不足なら403へ))
<?php
// 型を厳密に扱う
declare(strict_types=1);
// セッションを開始する
session_start();
// htmxリクエストか判定する
function isHx(): bool {
// HX-Requestヘッダを見る
return (($_SERVER['HTTP_HX_REQUEST'] ?? '') === 'true');
}
// ログイン状態を判定する
$isLogin = (bool)($_SESSION['is_login'] ?? false);
// ロールを取得する
$role = (string)($_SESSION['role'] ?? 'user');
// 未ログインならログインへ
if (!$isLogin) {
// ログイン後の戻り先
$next = '/htmx/demo/_redirect_auth_admin.php';
// htmxならHX-Redirect
if (isHx()) {
// ログインへ誘導する
header('HX-Redirect: /htmx/demo/_redirect_auth_login.php?next=' . rawurlencode($next));
// 終了する
exit;
}
// 通常ならLocation
header('Location: /htmx/demo/_redirect_auth_login.php?next=' . rawurlencode($next));
// 終了する
exit;
}
// admin以外なら403へ
if ($role !== 'admin') {
// htmxならHX-Redirectで403画面へ
if (isHx()) {
// 403ページへ誘導する
header('HX-Redirect: /htmx/demo/_redirect_auth_403.php');
// 終了する
exit;
}
// 通常ならLocation
header('Location: /htmx/demo/_redirect_auth_403.php');
// 終了する
exit;
}
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
?>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>管理者ページ | リダイレクト</title>
<!-- htmx -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<section
id="REDIRECT_APP"
class="SECTION"
hx-boost="true"
hx-target="#REDIRECT_APP"
hx-select="#REDIRECT_APP"
hx-swap="outerHTML"
>
<h1>管理者ページ(admin専用)</h1>
<p class="HTMX-NOTE">
admin権限がある場合のみ表示されます。<br>
userの場合は 403 画面へ誘導されます。
</p>
<ul class="HTMX-LIST">
<li>監査ログ:OK</li>
<li>権限設定:OK</li>
<li>危険操作:OK</li>
</ul>
<div class="FORM__FOOT">
<a class="BTN is-ghost" href="/htmx/demo/_redirect_auth.php">戻る</a>
</div>
</section>
</body>
</html>
PHP(403画面/_redirect_auth_403.php)
<?php
// 型を厳密に扱う
declare(strict_types=1);
// 403ステータスにする
http_response_code(403);
// HTMLとして返す
header('Content-Type: text/html; charset=UTF-8');
// HTMLエスケープ関数
function h(string $s): string {
// 特殊文字をエスケープする
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
}
?>
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>403 | 権限不足</title>
<!-- htmx -->
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
</head>
<body>
<section
id="REDIRECT_APP"
class="SECTION"
hx-boost="true"
hx-target="#REDIRECT_APP"
hx-select="#REDIRECT_APP"
hx-swap="outerHTML"
>
<h1>403:権限がありません</h1>
<p class="HTMX-NOTE">
この操作には権限が必要です。<br>
管理者に権限を付与してもらうか、別のアカウントでログインしてください。
</p>
<div class="FORM__FOOT">
<a class="BTN is-ghost" href="/htmx/demo/_redirect_auth.php">一覧へ戻る</a>
</div>
</section>
</body>
</html>
デモ
リダイレクト/ログイン誘導を扱う
解説
全体の流れ
- 未ログイン:保護ページや保存処理に来たら
HX-Redirectでログインへ - 保存成功:保存後は
HX-Locationで詳細ページへ(準SPAのまま遷移) - 権限不足:adminページへのアクセスは
HX-Redirectで403画面へ
HTMLでやっていること
- 全体を
#REDIRECT_APPで包み、hx-boost="true"でリンク/フォームをAJAX化します。 hx-target="#REDIRECT_APP"+hx-select="#REDIRECT_APP"で、“この箱だけ”を差し替える準SPAにします。hx-swap="outerHTML"で箱ごと置換し、遷移を重ねてもDOMが崩れにくい構造にします。- リンクは通常の
hrefも残すので、非JS環境でもSSRとして破綻しません。
PHPでやっていること
sessionでログイン状態(is_login)と権限(role)を管理します。- 未ログインなら、対象ページ側で
HX-Redirectを返してログインへ誘導します。 - 保存成功後は、
HX-Locationで詳細ページへ遷移させます(target/select/swapも同時指定)。 - admin専用ページでは
roleを検査し、不足なら403に誘導します。
HX-Redirect と HX-Location の使い分け
HX-Redirect:「別ページへ移動してね」(ログイン誘導/403など“強制的に飛ばしたい”場面に強い)HX-Location:「次の画面を読み直して表示してね」(保存→詳細など“自然な導線で遷移”したい場面に最適)
ポイント:遷移の判断はサーバー側に寄せて、フロントは“表示するだけ”にすると実装が安定します。
よくある詰まり(エラーまとめ)
- ヘッダが効かない:PHPで
echoした後にheader()を呼ぶと失敗します(必ず出力前にヘッダ送信)。 - 無限リダイレクト:ログインページ自身にも「未ログインならログインへ」判定を入れるとループします。
- 戻り先(next)が危険:
nextをそのまま使うとオープンリダイレクトになり得ます(同一ドメインのみ許可するのが安全)。 - DOMが崩れる:差し替えが入れ子になりやすいので、箱単位で
hx-swap="outerHTML"推奨。 - セッションが不安定:ログイン時は
session_regenerate_id(true)を入れると安全(固定化対策)。 - 403の扱いが曖昧:403画面は
http_response_code(403)を返しておくと意図が明確になります。
参考リンク
このページの著者
経験:Webアプリ/業務システム
得意:PHP・JavaScript・MySQL・CSS
個人実績:フォーム生成基盤/クイズ学習プラットフォーム 等
詳しいプロフィールはこちら! もちもちみかんのプロフィール