- Go の
html/template ベースのサーバーサイドレンダリングを維持しつつ、HTMX で動的なインタラクションを加え、1 つのレンダラーからページ全体と HTML フラグメントを選択的に返す
base.tmpl、ページテンプレート、再利用可能なフラグメントにマークアップを分け、Go 1.16 の embed.FS と共有テンプレートセットを活用して、デプロイとレンダリング構造を単純化する
HX-Request に応じて検索結果をページ全体またはテーブル行として返し、Vary: HX-Request と historyRestoreAsHxRequest: false によってキャッシュや戻る操作での復元エラーを防ぐ
- HTMX リクエストを別ページへ移動させる場合は、通常の 3xx ではなく
HX-Redirect と 2xx レスポンスを使い、204・422・4xx・5xx ごとの responseHandling で差し替え範囲とエラー表示を制御する
- ローカルストレージキャッシュと属性継承を無効にし、リクエストタイムアウトを設定する構成を出発点とする。規模が大きくなったら中間レイアウトテンプレートを追加し、管理者領域のような別画面構造にも対応できる
Go のサーバーレンダリングと HTMX を組み合わせる理由
- HTMX は少ない JavaScript だけで動的なインタラクションを追加しながら、Go
html/template のサーバーサイド HTML レンダリングがもたらす一貫性と安全性を維持できる
- 実装で扱う軸は 3 つある
- ページ全体と部分 HTML レスポンスをあわせてサポートするテンプレート構造
- HTMX 環境におけるリダイレクトとエラー処理
- HTMX のデフォルト値を変更する設定とその理由
- ボタンを画像に差し替える基本機能から始め、ユーザー名やメールアドレスを入力している間に一覧を絞り込む検索画面へ拡張する
- 同じテンプレートパターンは HTMX だけでなく、Unpoly や Hotwire のような HTML-over-the-wire ツールにも適用できる
- HTMX の基本的な使い方を知らない場合は、先に公式ドキュメントを確認するのがよい
プロジェクトディレクトリと静的ファイル構成
- プロジェクトは役割に応じて次の領域に分かれる
assets/html/base.tmpl: すべてのページが共有する HTML の骨格
assets/html/pages/: 個別ページのコンテンツ
assets/html/partials/: 複数箇所で再利用する HTML フラグメント
assets/static/css、assets/static/img、assets/static/js: 静的アセット
cmd/web/: サーバー起動、ハンドラー、HTML レンダラーのコード
- 基本の骨格は次のコマンドで生成する
go mod init example.com/htmx
mkdir -p assets/static/css assets/static/img assets/static/js assets/html/partials assets/html/pages cmd/web
touch assets/efs.go assets/html/base.tmpl assets/html/partials/images.tmpl assets/html/pages/home.tmpl cmd/web/main.go cmd/web/handlers.go cmd/web/html.go
- HTMX は CDN や NPM でインストールすることもできるが、コピーをダウンロードしてアプリケーションの静的ファイルとして直接配信する方式を主に使う
- 構成が単純で、CDN 利用に伴うデメリットを避けられる
htmx.org@2.0.10 とクラスレス CSS フレームワーク bamboo.css@1.4.0 を使う
wget -P assets/static/js https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js
wget -P assets/static/css https://cdn.jsdelivr.net/npm/bamboo.css@1.4.0/dist/bamboo.min.css
ベース・ページ・部分テンプレートの分離
base.tmpl は <head>、共通タイトル、<main> など文書全体の骨格を定義し、page:title と page:content を挿入する
- Bamboo CSS と HTMX スクリプトはベーステンプレートで読み込み、HTMX スクリプトには
defer 属性を指定する
- ブラウザーが HTML を解析している間にスクリプトを並列で取得する
- HTML の解析と DOM 構築が終わった後にスクリプトを実行する
- すべてのテンプレートはファイル名に依存せず、
{{define}}...{{end}} で明示的な名前を付ける
- Go コードは定義された名前だけでテンプレートを一貫して参照できる
page:title の : は任意の区切り文字であり、page_title、page-title、pageTitle、title のような名前も可能
- ホームページのボタンには 2 つの動作を指定する
hx-get="/gopher": クリック時に GET /gopher リクエストを送る
hx-swap="outerHTML": レスポンス HTML でボタン自体を差し替える
partial:image:gopher は再利用可能な画像フラグメントで、width="{{.}}" によって呼び出し時に画像幅を受け取る
- 特定のページでだけ使う HTML フラグメントは、共通の
partials ディレクトリではなく、そのページファイル内に置くことができる
- 複数ページで使うフラグメントは共通ディレクトリに置き、ページ専用のフラグメントは関連コンテンツのそばに配置するという区分である
Go バイナリに HTML と静的アセットを含める
- Go 1.16 でファイル埋め込みが追加されて以降は、実行時にディスクから読む代わりに、HTML と静的アセットを Go バイナリに含めることができる
//go:embed "html" "static" ディレクティブで 2 つのディレクトリを embed.FS に入れ、fs.Sub() を使って 2 つのサブファイルシステムに分離する
HTMLFiles: html をルートに使うテンプレートファイルシステム
StaticFiles: static をルートに使う静的ファイルシステム
- 分離して得られる利点は 2 つある
- HTML コードと静的ファイルコードが互いのファイルに不要にアクセスしない
- ファイルを開くときに
html/ または static/ のプレフィックスを付ける必要がない
sub() で panic() を使いたくない場合は、エラーを返して main() で 2 つの変数を初期化することもできる
- 現在の実装で
fs.Sub() が失敗するにはディレクトリ値が有効なパスでない必要があるが、固定文字列 "html" と "static" はこの検査を通るため、実行時パニックのリスクは非常に低い
共有テンプレートセットと htmlRenderer
htmlRenderer はレンダリングの流れを次のように管理する
- 起動時に共有テンプレートセットを一度パースする
- レンダリングのたびに共有セットを複製する
- リクエストに必要なページテンプレートを追加でパースする
- 指定した名前のテンプレートを実行し、HTTP レスポンスとして送信する
newHTMLRenderer() は template.FuncMap を登録した後、共通テンプレートをパースする
now を time.Now に接続する
- 他のユーザー定義テンプレート関数も同じ場所に追加できる
render() は sharedTemplates.Clone() でリクエストごとのテンプレートセットを作り、additionalTemplateFiles があれば ParseFS() で拡張する
- テンプレートはまず
bytes.Buffer に実行し、その後ステータスコードを記録してレスポンス本文として送信する
- サーバー初期化時に
"base.tmpl" と "partials/*.tmpl" を共有セットとしてパースする
- ベーステンプレートとすべての共通フラグメントをレンダラーで常に利用できる
- ページ別テンプレートだけをハンドラー呼び出し時に追加する
- 静的ファイルは
http.FileServerFS(assets.StaticFiles) と /static/ パスで提供し、サーバーは :5051 で実行される
ページ全体と HTML フラグメントのレンダリング
- ホームページのハンドラーは次の呼び出しで文書全体を返す
app.html.render(w, 200, nil, "base", "pages/home.tmpl")
- 共有セットに
pages/home.tmpl を追加し、base テンプレートを 200 OK で実行する呼び出しである
/gopher ハンドラーは追加ファイルをパースせず、共有セットにすでに含まれている partial:image:gopher だけを実行する
width := 100
app.html.render(w, http.StatusOK, width, "partial:image:gopher")
- HTMX は
/gopher から受け取った画像を、hx-swap="outerHTML" の設定に従ってボタンと置き換える
- この構造には次の利点がある
- テンプレートと静的アセットがバイナリに含まれるため、デプロイが容易になる
- 同じ
render() で HTML ページ全体と特定の HTML 断片の両方を返せる
- 基本テンプレートと部分テンプレートにより、マークアップの重複を減らせる
- 部分テンプレートを基本テンプレート、ページコンテンツ、ほかの部分テンプレートでも再利用できる
入力と同時に更新されるユーザー検索
- 検索機能は 2 つの経路に分かれる
GET /users: すべてのユーザー情報を含む HTML ページ全体
GET /users/search: 名前またはメールアドレスが検索語と一致するユーザーのテーブル行だけを返す HTML 断片
- 検索入力欄は次の HTMX 属性を組み合わせる
hx-get="/users/search" で検索リクエストを送る
hx-trigger="input changed delay:500ms, keyup[key=='Enter']" で入力変更後 500ms が経過するか、Enter を押すとリクエストする
- 検索語は
GET /users/search?query=foo 形式のクエリ文字列として渡される
hx-target="#search-results" でレスポンスを <tbody id="search-results"> の内部に入れる
hx-push-url="true" でリクエストごとにアドレスバーを更新し、ブラウザ履歴に項目を追加する
users:rows はユーザー行だけをレンダリングする別テンプレートである
- 各行には名前とメールアドレスが表示される
IsGopher が真なら、partial:image:gopher を幅 24 でレンダリングする
listUsers と searchUsers はどちらも pages/users.tmpl を共有セットに追加するが、実行するテンプレートは異なる
listUsers: base を実行してページ全体を返す
searchUsers: users:rows を実行して一致する行だけを返す
- 検索語が空ならユーザー一覧全体を使い、値があれば
strings.Contains() で名前とメールアドレスを検査する
- 実際のアプリケーションでは次の改善を検討できる
- 一覧ハンドラと検索ハンドラを 1 つにまとめる
- エラー処理とロギングのヘルパーを中央集約する
- ミドルウェアで Content Security Policy ヘッダーとパニックリカバリを追加する
- サーバーのタイムアウトを適切に設定する
HX-Request でページ全体と部分レスポンスを区別する
- ユーザーが
/users/search?query=leo をブラウザで直接開いたり、共有されたリンクから訪問したりすると、テーブル行に相当する部分 HTML だけが表示される場合がある
- HTMX リクエストには常に
HX-Request: true ヘッダーが含まれるため、これを基準にレスポンス形式を決める
func isHTMXRequest(r *http.Request) bool {
return r.Header.Get("HX-Request") == "true"
}
- 検索ハンドラのデフォルト実行テンプレートを
base にしておき、HTMX リクエストのときだけ users:rows に変更する
- 直接訪問した場合は、検索結果を含むページ全体を受け取る
- HTMX リクエストには
<tbody> に入れる行の断片だけを返す
Vary ヘッダーと戻る操作の復元
- 同じ URL が
HX-Request の値に応じて異なるレスポンスを返すため、キャッシュにもこの違いを知らせる必要がある
- すべてのレンダリングレスポンスに
Vary: HX-Request を追加する
- 厳密には、個別ハンドラで必要な場合だけ設定するほうが無駄は少ない
- レンダラーで一括設定すれば、設定漏れによって起こり得るバグを避けられる
hx-push-url や hx-boost がブラウザ履歴を追加すると、HTMX は完成したページ HTML をブラウザのローカルストレージにキャッシュする
- デフォルトのキャッシュは最大 10ページ
- キャッシュにない地点まで戻ると、HTMX はサーバーに該当 URL を再リクエストする
- デフォルトの復元リクエストには
HX-Request: true が含まれるため、サーバーがページ全体ではなく部分 HTML を返してしまう可能性がある
HX-Request でレスポンス種別を選ぶなら、historyRestoreAsHxRequest: false を設定する
- キャッシュミス時の復元リクエストから
HX-Request: true を除外する
- サーバーがそのリクエストに HTML ページ全体を返すようになる
- HTMX の設定は
base.tmpl の htmx-config メタタグに入れる
HTMX リクエストを別ページへリダイレクトする
- HTMX は成功メッセージのような HTML 断片を即座に置き換えられるため、一般的な Post/Redirect/Get フローが必要なケースを減らせる
- ログイン成功後にプロフィールへ移動するように、完全に別ページへ送る必要がある場合は、通常の 3xx レスポンスだけでは処理できない
- ブラウザが HTMX より先に 3xx レスポンスに従う
- HTMX はリダイレクト後の最終レスポンスだけを確認し、そのコンテンツを既存のターゲットに置き換える
- HTMX リクエストには 2xx レスポンスと
HX-Redirect ヘッダーを一緒に使う
HX-Redirect: /foo/bar はブラウザをその URL に移動させ、ページ全体を再読み込みする
- 移動先リクエストには
HX-Request: true は含まれない
- JavaScript が無効、または HTMX が読み込まれていない環境もサポートするなら、HTMX ではないリクエストには通常の 3xx レスポンスを維持する必要がある
func redirect(w http.ResponseWriter, r *http.Request, url string, code int) {
if isHTMXRequest(r) {
w.Header().Set("HX-Redirect", url)
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, url, code)
}
- ログイン後に
/profile へ移動する場合は、次のように呼び出せる
redirect(w, r, "/profile", http.StatusSeeOther)
HX-Redirect と HX-Location の違い
HX-Location はページ全体を再読み込みせず、リダイレクトに似た動作を行う
- 対象 URL の HTML を取得する
- レスポンスを
<body> に置き換える
- ブラウザ履歴に新しい項目を追加する
- 対象 URL をリクエストするときは
HX-Request: true が含まれる
- 対象パスが
HX-Request に応じて部分 HTML を返す場合、問題が起こる可能性がある
- ハンドラは
HX-Location によって来たリクエストと通常の HTMX リクエストを区別できない
- 前者にはページ全体が必要だが、後者には部分レスポンスが必要である
- 履歴復元とは異なり、
HX-Location による移動で HX-Request: true をオフにする設定はない
- 多くの場合、ページ全体の再読み込みを受け入れて
HX-Redirect を使うほうが簡単で安全
- 移動先パスが常に HTML 全体だけを返すように構成されているなら、
HX-Location も利用できる
204・422・4xx・5xx レスポンスの処理
- HTMX はデフォルトでは 4xx または 5xx レスポンスを DOM に置き換えない
- 現在の画面をそのまま維持する
- エラーは開発者ツールのコンソールに記録される
- 存在しないテンプレート名が原因でサーバーが
500 Internal Server Error を返しても、デフォルト設定ではユーザーが画面上でエラーを確認できない
- エラーメッセージをユーザーに見せるため、4xx と 5xx レスポンスは
<body> 全体に置き換える よう設定する
- 例外は
422 Unprocessable Content である
- フォーム検証エラーを含むフォームを返すときに使う
- 既存の HTMX ターゲットにレスポンスが正常に置き換えられる必要がある
responseHandling はステータスコードに応じて次のように設定する
204 No Content: DOM を変更しない
422 Unprocessable Content: 既存のターゲットに通常どおり置き換える
- その他の 4xx と 5xx: レスポンスを
<body> に置き換える
- それ以外のレスポンス: 既存のターゲットに通常どおり置き換える
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
]
- 特定のインタラクションでエラーを
<body> ではない別の対象に入れる必要がある場合は、response target 拡張機能でデフォルト設定を上書きできる
ブラウザのアドレスバーの現在の URL を確認する
- HTMX は
hx-boost、hx-push-url、hx-replace-url を使わない限り、AJAX リクエストとレスポンス置換の過程でブラウザの URL を変更しない
- そのため、Go ハンドラーの
r.URL と、ユーザーがアドレスバーで見ている URL が異なる場合がある
- HTMX は各リクエストの
HX-Current-URL ヘッダーで、ブラウザが現在表示している URL を渡す
- ヘルパー関数はこのヘッダーを
url.URL としてパースし、ヘッダーがなければ r.URL を返す
func browserURL(r *http.Request) (*url.URL, error) {
cu := r.Header.Get("HX-Current-URL")
if cu != "" {
return url.Parse(cu)
}
return r.URL, nil
}
HTMX のデフォルト値を変更する設定
historyCacheSize: 0 で HTMX の ローカルストレージのページキャッシュを完全にオフにする
- ローカルストレージのキャッシュはバグやセキュリティ問題の原因になり得る
- 戻るボタンを押すと、キャッシュの代わりにサーバーから HTML を再取得する
- 今後の HTMX バージョンでは、同じ理由からローカルストレージのキャッシュはデフォルトで無効化される予定
disableInheritance: true で HTMX 属性の継承を無効化する
- 属性は常に明示的に宣言するほうが明確
- バグや意図しない動作のリスクを下げる
- 今後の HTMX バージョンでは、属性の継承もデフォルトで無効化される予定
includeIndicatorStyles: false で、HTMX がインジケータースタイルを注入しないようにする
- インジケータースタイルを他の CSS ルールとあわせて自分で定義し、一貫性を保つ
- HTMX リクエストにはデフォルトでタイムアウトがなく、サーバーが応答するまで待つ
- Go アプリケーションのタイムアウトとデッドラインの扱い方に応じて、
timeout をミリ秒単位で指定できる
- 出発点として
timeout: 5000 を使う
- 基本設定には
historyRestoreAsHxRequest: false と、ステータスコード別の responseHandling も含まれる
<meta
name="htmx-config"
content='{
"includeIndicatorStyles": false,
"historyCacheSize": 0,
"historyRestoreAsHxRequest": false,
"responseHandling":[
{"code":"204", "swap": false},
{"code":"422", "swap": true},
{"code":"[45]..", "swap": true, "target": "body"},
{"code":"...", "swap": true}
],
"timeout": 5000
}'
>
大規模アプリケーション向けのページ別レイアウト
- ベーステンプレートとページ別テンプレートだけでは足りない場合、2 つの階層の間に レイアウトテンプレートを追加できる
- 管理者ページのように、通常ページとは異なるタイトル、ナビゲーションメニュー、本文構造が必要な領域に適している
base は page:content を直接呼び出す代わりに layout を呼び出す
<body>
{{template "layout" .}}
</body>
layouts/admin.tmpl は管理者領域の共通構造を定義し、その内部で page:content を呼び出す
- 管理者領域のタイトル
- ユーザーと注文へ移動するナビゲーションメニュー
- ページ別コンテンツが入る
<main>
- ハンドラーは
render() に、使用するレイアウトとページテンプレートをまとめて渡す
app.html.render(
w,
200,
nil,
"base",
"layouts/admin.tmpl",
"pages/admin-orders.tmpl",
)
- 共有のベーステンプレートを維持しながら、パスごとのレイアウトとページコンテンツを組み合わせられるため、管理者領域のように別構造が必要な画面も同じレンダリングパターンで拡張できる
1件のコメント
Hacker News の意見
Go + HTMX が好きで、テンプレート、コンポーネント、部分 HTML の型安全性を高めるために a-h/templ と一緒に使っている ツールセット全体を Go、Unix、SQLite で構成した GUS スタックと呼んで公開した: https://housecat.com/blog/the-gus-stack-go-unix-sqlite exe.dev の GUTS スタックに大きく触発されたが、TypeScript の代わりに HTMX を使っている: https://exe.dev/docs/guts スタックトレース付きエラーには cockroachdb/errors、型安全な HTML には templ、OpenAPI 仕様の生成には fuego、SQL コード生成には sqlc、純 Go の SQLite には modernc.org/sqlite、マイグレーションには goose、永続化ワークフローには dbos、Chrome/CDP テストと自動化には rod を使っている この組み合わせは、自分でコーディングする場合でもエージェントの助けを借りてコーディングする場合でも、単一バイナリをビルドしてデプロイするプロセス全体で生産的だ
go generate ./...をビルド段階として受け入れると得るものは多く、goverter で sqlc モデルとテンプレートオブジェクト・戻り値の間の変換も生成している ボイラープレートの約 50% が自動生成され、型安全性が非常に良くなるHTMX は多くの用途で素晴らしいが、チームメンバーが同意しなければ導入は非常に難しい 本格的な技術ではないという反発が続き、さまざまなバグがまず HTMX のせいにされ、後で誤解だと分かってもすでに信頼は損なわれている これまで働いた中で最も優秀なチームでもそうだったし、結局は戦う相手を選ばなければならないという教訓を得た Go の html/template もインターフェイスが妙に不自然だと思う。『A Philosophy of Software Design』が勧めるように複雑さは内部に押し込めるべきなのに、HTML をレンダリングするたびにテンプレートの複製を気にしなければならない構造は不必要に複雑だ
base.tmplをBASE_BEGINとBASE_ENDに分けて最終テンプレートで使えばよい ページごとのタイトルを実行時に渡す必要はあるが、元記事の方式も 多言語対応を始めれば結局は破綻する最近のプロジェクトで HTMX をとても楽しく使っている AngularJS と React 以前の Web を知っている立場としては、実際のページを作り、JavaScript の量を最小限に抑えられる点が特に良い 純粋な JavaScript でも可能だが、HTMX は繰り返し出てくるイベント処理のボイラープレートを代わりに担ってくれる 現代のフロントエンド哲学が好きでないなら試してみる価値があり、公式サイトの最初の例はごく基本的だが、もう少し学ぶとずっと強力になる
かなり大きなプロジェクトを HTMX + Go で作ってみたが、まだ十分ではなく、今後も望むレベルに到達するかは確信できない 単純な CRUD アプリや管理ダッシュボードには素晴らしいが、相互につながったコンポーネント、共有状態、複雑なインタラクションが増えると、管理が急速に難しくなる React が嫌で HTMX を選んだものの、結局バックエンドは Go のままにし、フロントエンドは SvelteKit の SPA モードに変更した。両者をきれいに分離することで、複雑な UI をはるかに簡単に開発・保守できた Svelte は JSX という別言語よりも HTML の自然な拡張のように感じられ、状態管理とコンポーネントモデルがシンプルで、新しい
$state構文も特に良い最近のサイドプロジェクトは、LLM の助けがあるかどうかに関係なく、ほぼすべて Go + HTMX で書いている Opus と GPT もこの組み合わせを非常にうまく扱い、素早く作って始められ、単一バイナリなのでデプロイとホスティングも便利 高速な反復開発にとても良いスタック
HTMX を使うなら、バックエンドでも React のように共通の HTML 片を関数として抽出して コンポーネント化しやすい HTML 生成方式を強く勧める HTMX では状況に応じて、特定の HTML のすぐ隣にタグを付けたり外したりする必要があるなど、バックエンドが生成する HTML に柔軟性が必要になる。たとえば最上位で `` タグを見つけるとページタイトルを更新する 伝統的な文字列ベースのテンプレートエンジンではこうした作業は難しいが、言語埋め込み型 HTML ライブラリでは簡単で、言語全体の抽象化機能を活用できるため HTMX と特に相性が良い JavaScript バックエンドなら https://github.com/WebReflection/uhtml-ssr のようなタグ付きテンプレートリテラル関数、Go なら https://www.gomponents.com/、Scala なら ScalaTags を使える
renderToStaticMarkupがあるが、バックエンド専用に書かれた JSX ライブラリを選ぶほうがよいだろうAlex Edwards の本と Learn Go with Tests で初めて Go に触れ、今でも勧めている 古いプロジェクトの一部を HTMX に移行してみたくなった
Kotlin + HTMX ベースのフレームワークを個人用途で作った: https://github.com/reubenfirmin/zoned Web アプリ全体をエンドツーエンドで型安全にできるか確認することが目標で、JSX に似た DSL で HTML を書く Kotlinx.html を使っている 最初の 90% は自分で作り、最後の 10% と README は公開を仕上げるために Claude の助けを借りた
Go も Alex Edwards も好きだが、HTMX を使った結果にはいつも失望してきた コードベースの複雑さがアプリ自体の 2 倍の速さで増えていくように感じ、奇妙な回避策なしには抜け出せない境界条件にいつも出くわす Node パッケージを嫌う理由は理解できるが、HTMX はそれに対する過剰補償のように見える。JSON と格闘しないことで節約した時間より、アプリの見た目と使い心地をきちんと作るのにかかる時間のほうが 3 倍大きい Mantine テンプレートなら 2 分で構成して優れた UI コンポーネントを活用でき、ビルド済みの静的アセットを含めれば同じように単一の Go バイナリにできる: https://github.com/mantinedev/vite-min-template
Go + Datastar のほうがシンプルなので、はるかに好んでいる