htmx 逆引きレシピ
直リンクでも破綻しない設計にするには?

公開日:
最終更新日:

最初にSSRでページ全体を成立させ、直URL・リロード・非JSでも成立する土台を作ります。

その上に hx-boost / hx-select / hx-target / hx-disinherit を重ねると、安全に“部分更新”を導入できます。

使用するhtmx属性

タグ:hx-boost / hx-select / hx-target / hx-disinherit

  • hx-boost:普通のリンク/フォームを、壊さずに部分更新へ上乗せします。
  • hx-select:フルHTMLレスポンスから必要領域(例:#MAIN)だけ採用します。
  • hx-target:差し替え先を明確化し、共通レイアウトを維持します。
  • hx-disinherit:ダウンロード・外部リンク・フル遷移をboost対象から除外します。

利用シーン

  • 直URLで開いてもOK:共有リンクやブックマーク、リロードでも「画面が成立する」SSR土台にしておきたい
  • JSなしでも最低限動く:まずは普通のリンク/フォームとして完結させ、JSがある時だけboostで快適化したい
  • 必要な部分だけ差し替える:ヘッダ/サイドは固定のまま #MAIN だけ更新し、遷移のチラつきと再描画コストを減らしたい

共通PHP(全文)

エスケープ関数・URL補助・メイン描画・フルHTML出力を _deep_link_safe_data.php に集約しています。
すべての表示値は共通関数経由で htmlspecialchars を適用します。

PHP(_deep_link_safe_data.php)

/htmx/demo/_deep_link_safe_data.php
<?php
// 型を厳密に扱う
declare(strict_types=1);

// HTML特殊文字を安全化する
function deep_link_safe_h(string $value): string
{
	// エスケープ済み文字列を返す
	return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}

// リクエスト文字列を安全に取り出す
function deep_link_safe_request_text(array $source, string $key, string $fallback): string
{
	// 文字列を取り出して前後空白を除去する
	$raw = trim((string)($source[$key] ?? ''));

	// 空文字なら既定値を返す
	if ($raw === '') {
		// 既定値を返す
		return $fallback;
	}

	// 入力値を返す
	return $raw;
}

// 表示ビューを安全に決める
function deep_link_safe_request_view(array $source): string
{
	// view値を取得する
	$view = deep_link_safe_request_text($source, 'view', 'a');

	// 許可ビューを定義する
	$allowed = ['a', 'b', 'c'];

	// 不正値ならaへ補正する
	if (!in_array($view, $allowed, true)) {
		// aへ補正する
		$view = 'a';
	}

	// 補正後viewを返す
	return $view;
}

// 表示デモ番号を安全に決める
function deep_link_safe_request_demo(array $source): string
{
	// demo値を取得する
	$demo = deep_link_safe_request_text($source, 'demo', '1');

	// 許可デモを定義する
	$allowed = ['1', '2', '3'];

	// 不正値なら1へ補正する
	if (!in_array($demo, $allowed, true)) {
		// 1へ補正する
		$demo = '1';
	}

	// 補正後demoを返す
	return $demo;
}

// 一覧元データを返す
function deep_link_safe_items(): array
{
	// 一覧データを返す
	return [
		['id' => 'A-100', 'title' => 'ダッシュボード構成の見直し', 'owner' => 'tanaka'],
		['id' => 'A-110', 'title' => '監査ログの保存期間を更新', 'owner' => 'sato'],
		['id' => 'B-200', 'title' => '一括メール配信の導線調整', 'owner' => 'suzuki'],
		['id' => 'B-210', 'title' => '管理画面ヘルプを再編', 'owner' => 'kato'],
		['id' => 'C-300', 'title' => '運用手順書のリンク更新', 'owner' => 'tanaka'],
		['id' => 'C-310', 'title' => 'エクスポート仕様の確認', 'owner' => 'sato'],
	];
}

// クエリで一覧を絞り込む
function deep_link_safe_filter_items(string $q): array
{
	// 検索語を小文字化する
	$q = mb_strtolower(trim($q));

	// 元データを取得する
	$items = deep_link_safe_items();

	// 検索語が空なら全件返す
	if ($q === '') {
		// 全件返す
		return $items;
	}

	// 抽出結果配列を初期化する
	$picked = [];

	// 1件ずつ確認する
	foreach ($items as $row) {
		// タイトルを取得する
		$title = (string)($row['title'] ?? '');

		// 担当を取得する
		$owner = (string)($row['owner'] ?? '');

		// タイトル一致を判定する
		$hitTitle = (mb_stripos(mb_strtolower($title), $q) !== false);

		// 担当一致を判定する
		$hitOwner = (mb_stripos(mb_strtolower($owner), $q) !== false);

		// どちらか一致なら採用する
		if ($hitTitle || $hitOwner) {
			// 結果へ追加する
			$picked[] = $row;
		}
	}

	// 抽出結果を返す
	return $picked;
}

// ビュー情報を返す
function deep_link_safe_view_meta(string $view): array
{
	// ビュー情報マップを定義する
	$map = [
		'a' => [
			'label' => 'view=a',
			'title' => '概要ビュー',
			'desc'  => 'SSRで成立する共通レイアウトを維持しつつ、MAINだけを差し替えるビューです。',
		],
		'b' => [
			'label' => 'view=b',
			'title' => 'レポートビュー',
			'desc'  => 'リンクとフォームを通常HTMLとして書き、JSあり時のみboostで部分更新します。',
		],
		'c' => [
			'label' => 'view=c',
			'title' => '運用ビュー',
			'desc'  => 'boost除外リンクを混在させる運用向けビューです。',
		],
	];

	// 対象情報を返す
	return $map[$view] ?? $map['a'];
}

// URL文字列を組み立てる
function deep_link_safe_build_url(string $selfPath, string $demo, string $view, string $q, bool $withDemo): string
{
	// パラメータ配列を初期化する
	$params = ['view' => $view];

	// demoを含める場合は追加する
	if ($withDemo) {
		// demoを追加する
		$params['demo'] = $demo;
	}

	// 検索語が空でなければ追加する
	if ($q !== '') {
		// qを追加する
		$params['q'] = $q;
	}

	// クエリ文字列を作る
	$query = http_build_query($params);

	// URLを返す
	return $selfPath . '?' . $query;
}

// MAIN領域のHTMLを返す
function deep_link_safe_render_main(string $selfPath, string $demo, string $view, string $q, bool $withDemo): string
{
	// ビュー情報を取得する
	$meta = deep_link_safe_view_meta($view);

	// ビューラベルを取得する
	$viewLabel = deep_link_safe_h((string)($meta['label'] ?? 'view=a'));

	// ビュータイトルを取得する
	$viewTitle = deep_link_safe_h((string)($meta['title'] ?? '概要ビュー'));

	// ビュー説明を取得する
	$viewDesc = deep_link_safe_h((string)($meta['desc'] ?? ''));

	// 検索語をエスケープする
	$qEsc = deep_link_safe_h($q);

	// ビューaリンクを作る
	$linkA = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'a', $q, $withDemo));

	// ビューbリンクを作る
	$linkB = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'b', $q, $withDemo));

	// ビューcリンクを作る
	$linkC = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'c', $q, $withDemo));

	// 検索結果を取得する
	$rows = deep_link_safe_filter_items($q);

	// HX-Requestヘッダ値を取得する
	$hxHeader = deep_link_safe_h((string)($_SERVER['HTTP_HX_REQUEST'] ?? '(none)'));

	// HTMLバッファを開始する
	ob_start();
	?>
	<main id="MAIN" class="SECTION">

		<h2><?= $viewTitle ?>(<?= $viewLabel ?>)</h2>

		<p class="HTMX-NOTE"><?= $viewDesc ?></p>

		<div class="FORM-RESULT is-ok">
			<p><strong>server response:</strong> 常にフルHTMLを返却(クライアント側で <code>hx-select="#MAIN"</code> を適用)</p>
			<p><strong>HX-Request:</strong> <?= $hxHeader ?></p>
		</div>

		<nav class="NAV">
			<a class="BTN" href="<?= $linkA ?>">view=a</a>
			<a class="BTN" href="<?= $linkB ?>">view=b</a>
			<a class="BTN" href="<?= $linkC ?>">view=c</a>
		</nav>

		<?php if ($demo === '2'): ?>
			<section class="CARD MT1rm">
				<h3>通常フォーム(非JSでも動く)</h3>
				<form class="FORM" method="get" action="<?= deep_link_safe_h($selfPath) ?>">
					<?php if ($withDemo): ?>
						<input type="hidden" name="demo" value="<?= deep_link_safe_h($demo) ?>">
					<?php endif; ?>
					<input type="hidden" name="view" value="<?= deep_link_safe_h($view) ?>">
					<label>
						キーワード
						<input type="text" name="q" value="<?= $qEsc ?>" placeholder="例: tanaka / 運用">
					</label>
					<button class="BTN is-ok" type="submit">検索</button>
				</form>
			</section>

			<section class="CARD MT1rm">
				<h3>検索結果</h3>
				<?php if ($rows === []): ?>
					<p class="HTMX-NOTE">該当データがありません。</p>
				<?php else: ?>
					<ul class="HTMX-LIST">
						<?php foreach ($rows as $row): ?>
							<li>
								<strong><?= deep_link_safe_h((string)($row['id'] ?? '')) ?></strong>
								<?= deep_link_safe_h((string)($row['title'] ?? '')) ?>
								(<span><?= deep_link_safe_h((string)($row['owner'] ?? '')) ?></span>)
							</li>
						<?php endforeach; ?>
					</ul>
				<?php endif; ?>
			</section>
		<?php endif; ?>

		<?php if ($demo === '3'): ?>
			<section class="CARD MT1rm">
				<h3>boost除外リンク(局所無効化)</h3>
				<ul class="HTMX-LIST">
					<li><a class="BTN" href="/htmx/demo/_deep_link_safe_data.php" download hx-disinherit="hx-boost">設定データDL(通常遷移)</a></li>
					<li><a class="BTN" href="https://htmx.org/docs/" target="_blank" rel="noopener noreferrer" hx-disinherit="hx-boost">外部ドキュメント(通常遷移)</a></li>
					<li><a class="BTN" href="/htmx/demo/_deep_link_safe_page.php?demo=3&amp;view=a" hx-disinherit="hx-boost">管理画面フル遷移(通常遷移)</a></li>
				</ul>
			</section>

			<section class="CARD MT1rm" hx-disinherit="hx-boost">
				<h3>モーダル内リンクは通常遷移の例</h3>
				<p class="HTMX-NOTE">このカード内のリンクは <code>hx-disinherit="hx-boost"</code> によりboostを継承しません。</p>
				<p><a class="BTN" href="/htmx/demo/_deep_link_safe_page.php?demo=3&amp;view=b">通常遷移で開く</a></p>
			</section>
		<?php endif; ?>

	</main>
	<?php

	// バッファ文字列を返す
	return (string)ob_get_clean();
}

// ページ全体を出力する
function deep_link_safe_emit_page(string $selfPath, string $demo, string $view, string $q, bool $withDemo): void
{
	// コンテンツタイプを返す
	header('Content-Type: text/html; charset=UTF-8');

	// ページタイトルを作る
	$pageTitle = 'deep-link-safe demo ' . $demo;

	// MAIN領域を作る
	$mainHtml = deep_link_safe_render_main($selfPath, $demo, $view, $q, $withDemo);

	// ビューaリンクを作る
	$asideA = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'a', $q, $withDemo));

	// ビューbリンクを作る
	$asideB = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'b', $q, $withDemo));

	// ビューcリンクを作る
	$asideC = deep_link_safe_h(deep_link_safe_build_url($selfPath, $demo, 'c', $q, $withDemo));

	// 自己URLを安全化する
	$selfEsc = deep_link_safe_h($selfPath);
	?>
	<!doctype html>
	<html lang="ja">
	<head>
		<meta charset="utf-8">
		<meta name="viewport" content="width=device-width,initial-scale=1">
		<title><?= deep_link_safe_h($pageTitle) ?></title>
		<style>
		body{margin:0;padding:0;font-family:sans-serif;line-height:1.5;background:#fafafa}
		.HEADER{padding:1rem;background:#fff;border-bottom:1px solid #ddd}
		.LAYOUT{display:grid;grid-template-columns:240px 1fr;gap:1rem;padding:1rem}
		.ASIDE{background:#fff;border:1px solid #ddd;border-radius:8px;padding:1rem}
		.SECTION{background:#fff;border:1px solid #ddd;border-radius:8px;padding:1rem}
		.NAV,.FORM__FOOT{display:flex;flex-wrap:wrap;gap:.5rem}
		.BTN{display:inline-block;padding:.45rem .75rem;border:1px solid #777;border-radius:6px;text-decoration:none;color:#222;background:#fff}
		.BTN.is-ok{border-color:#2e7d32;color:#2e7d32}
		.FORM{display:grid;gap:.5rem}
		.FORM input{padding:.5rem;border:1px solid #bbb;border-radius:6px}
		.FORM-RESULT{margin-top:.75rem;padding:.75rem;border:1px dashed #bbb;border-radius:8px;background:#fcfcfc}
		.FORM-RESULT.is-ok{border-color:#2e7d32;background:#f3fff4}
		.CARD{margin-top:.75rem;padding:.75rem;border:1px solid #e2e2e2;border-radius:8px;background:#fff}
		.HTMX-NOTE{color:#555}
		.HTMX-LIST{padding-left:1.2rem}
		.FOOTER{padding:1rem;text-align:center;color:#666}
		@media (max-width:800px){.LAYOUT{grid-template-columns:1fr}}
		</style>
		<script src="/htmx/htmx.min.js" defer></script>
	</head>
	<body>

		<header class="HEADER">
			<h1>DEMO<?= deep_link_safe_h($demo) ?>: SSRを土台にboostを上乗せする</h1>
			<p class="HTMX-NOTE">直リンク・リロード・非JSでも成立するよう、サーバは常にフルHTMLを返します。</p>
		</header>

		<div
			id="APP"
			class="LAYOUT"
			hx-boost="true"
			hx-select="#MAIN"
			hx-target="#MAIN"
			hx-swap="innerHTML"
		>

			<aside class="ASIDE">
				<h2>共通サイド</h2>
				<p class="HTMX-NOTE">レイアウトは固定し、<code>#MAIN</code> だけ更新します。</p>
				<ul class="HTMX-LIST">
					<li><a href="<?= $asideA ?>">view=a</a></li>
					<li><a href="<?= $asideB ?>">view=b</a></li>
					<li><a href="<?= $asideC ?>">view=c</a></li>
				</ul>
				<p><a class="BTN" href="<?= $selfEsc ?>">初期状態へ</a></p>
			</aside>

			<?= $mainHtml ?>

		</div>

		<footer class="FOOTER">
			<p>共通フッター(SSR固定)</p>
		</footer>

	</body>
	</html>
	<?php
}

PHP(_deep_link_safe_page.php)

/htmx/demo/_deep_link_safe_page.php
<?php
// 型を厳密に扱う
declare(strict_types=1);

// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');

// demo番号を取得する
$demo = deep_link_safe_request_demo($_GET);

// view番号を取得する
$view = deep_link_safe_request_view($_GET);

// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');

// フルページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_page.php', $demo, $view, $q, true);

① hx-boostで通常リンクをPJAX化(直リンクでも破綻しない)

サーバは常にフルHTMLを返し、クライアントは hx-select="#MAIN" + hx-target="#MAIN" + hx-swap="innerHTML" で必要部分だけ反映します。

HTML(view全文)

_demo_htmx_1.php
<div class="DEMO">

	<h4>DEMO1: hx-boostで通常リンクを部分遷移化(直リンクでも破綻しない)</h4>

	<p class="HTMX-NOTE">
		SSRで全体を返し続けながら、<code>#MAIN</code> だけを差し替えます。<br>
		<a class="BTN" href="/htmx/demo/_deep_link_safe_1.php?view=a" target="_blank" rel="noopener">別タブで開く</a>
	</p>

	<div class="CARD">
		<iframe
			src="/htmx/demo/_deep_link_safe_1.php?view=a"
			title="DEMO1: 直リンクでも破綻しない"
			style="width:100%;min-height:620px;border:1px solid #ddd;border-radius:8px;background:#fff;"
			loading="lazy"
		></iframe>
	</div>

</div>

PHP(_deep_link_safe_1.php)

/htmx/demo/_deep_link_safe_1.php
<?php
// 型を厳密に扱う
declare(strict_types=1);

// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');

// view番号を取得する
$view = deep_link_safe_request_view($_GET);

// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');

// DEMO1ページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_1.php', '1', $view, $q, false);

デモ

DEMO1: hx-boostで通常リンクを部分遷移化(直リンクでも破綻しない)

SSRで全体を返し続けながら、#MAIN だけを差し替えます。
別タブで開く

② 非JSでも最低限動く(リンク/フォーム)+JSありで部分更新

UIは普通の a / form のままです。htmxが居る場合だけboost化され、非JSでも同じURLで成立します。

HTML(view全文)

_demo_htmx_2.php
<div class="DEMO">

	<h4>DEMO2: JSなしでも最低限動く(リンク/フォーム)+JSありで部分更新</h4>

	<p class="HTMX-NOTE">
		同じ <code>a</code> / <code>form</code> を使い、非JSなら通常遷移、JSありならboostで <code>#MAIN</code> だけ更新します。<br>
		<a class="BTN" href="/htmx/demo/_deep_link_safe_2.php?view=b" target="_blank" rel="noopener">別タブで開く</a>
	</p>

	<div class="CARD">
		<iframe
			src="/htmx/demo/_deep_link_safe_2.php?view=b"
			title="DEMO2: 非JSでも成立するUI"
			style="width:100%;min-height:650px;border:1px solid #ddd;border-radius:8px;background:#fff;"
			loading="lazy"
		></iframe>
	</div>

</div>

PHP(_deep_link_safe_2.php)

/htmx/demo/_deep_link_safe_2.php
<?php
// 型を厳密に扱う
declare(strict_types=1);

// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');

// view番号を取得する
$view = deep_link_safe_request_view($_GET);

// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');

// DEMO2ページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_2.php', '2', $view, $q, false);

デモ

DEMO2: JSなしでも最低限動く(リンク/フォーム)+JSありで部分更新

同じ a / form を使い、非JSなら通常遷移、JSありならboostで #MAIN だけ更新します。
別タブで開く

③ hx-disinheritでboost除外リンクを局所指定する

外部リンク・ダウンロード・管理画面のフル遷移など、boostしたくない導線だけ hx-disinherit="hx-boost" で除外できます。

HTML(view全文)

_demo_htmx_3.php
<div class="DEMO">

	<h4>DEMO3: hx-disinheritで「boostしたくないリンク」を除外</h4>

	<p class="HTMX-NOTE">
		DL・外部リンク・管理画面フル遷移を局所的に通常遷移へ戻します。<br>
		<a class="BTN" href="/htmx/demo/_deep_link_safe_3.php?view=c" target="_blank" rel="noopener">別タブで開く</a>
	</p>

	<div class="CARD">
		<iframe
			src="/htmx/demo/_deep_link_safe_3.php?view=c"
			title="DEMO3: hx-disinherit の局所無効化"
			style="width:100%;min-height:690px;border:1px solid #ddd;border-radius:8px;background:#fff;"
			loading="lazy"
		></iframe>
	</div>

</div>

PHP(_deep_link_safe_3.php)

/htmx/demo/_deep_link_safe_3.php
<?php
// 型を厳密に扱う
declare(strict_types=1);

// 共通関数を読み込む
require_once(__DIR__ . '/_deep_link_safe_data.php');

// view番号を取得する
$view = deep_link_safe_request_view($_GET);

// 検索語を取得する
$q = deep_link_safe_request_text($_GET, 'q', '');

// DEMO3ページを出力する
deep_link_safe_emit_page('/htmx/demo/_deep_link_safe_3.php', '3', $view, $q, false);

デモ

DEMO3: hx-disinheritで「boostしたくないリンク」を除外

DL・外部リンク・管理画面フル遷移を局所的に通常遷移へ戻します。
別タブで開く

次に読むオススメレシピ

このページの著者

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

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

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

得意:PHP・JavaScript・MySQL・CSS

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

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

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

もちもちみかん.comとは


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

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