
2026/07/15 4:55
HTMX を Go で活用する方法
RSS: https://news.ycombinator.com/rss
要約▶
日本語翻訳:
著者は、HTMX を活用して大規模なクライアントサイド JavaScript の導入を必要とせず、極めてインタラクティブな体験を提供する Go ベースの Web アーキテクチャの推進を唱えている。このアプローチは、
//go:embed を使用してすべての静的アセット(HTML テンプレートを含む)を Go バイナリに直接埋め込み、外部 CDN への依存関係を排除することで、サーバーサイドの安全性を維持し、デプロイの簡素化を実現する。カスタムレンダリングエンジンは起動時に共有テンプレートを解析し、フルページと動的な部分(例:{{template "page:content" .}})の両方を効率的に処理するために、スケーラビリティを考慮した構造化されたディレクトリレイアウトに整理する。インタラクティブ性はすべて標準 HTTP リクエストにより駆動され、hx-get、hx-swap、hx-push-url といった HTMX 属性によって実現される。正しいキャッシュ処理と履歴ナビゲーションを確保するために、システムは Vary: HX-Request のような特定のヘッダーを設定し、HTMX を構成してブラウザの履歴において部分 HTML がキャッシュされるのを防ぐ。さらに、このアーキテクチャは HTMX リクエストと直接ナビゲーションリクエストの違いを条件付きロジック(HX-Redirect と標準のリダイレクト)を用いて区別し、エラー応答をカスタマイズしてエラーにもフルページ HTML を表示することで、サーバーサイドレンダリングによって支えられたシームレスなユーザー体験を実現する。本文
Go と HTMX を組み合わせて Web アプリを構築する
Web アプリに「インタラクティブさ」を加えたい場合、私は HTMX を多用しています。HTMX は以下の点で特に優れています:
- 自然なような滑らかな感覚を与えられる
- JavaScript の記述量を最小限に抑えられる
- Go の
パッケージによるサーバーサイド HTML レンダリングの一貫性と安全性を維持できるhtml/template
この記事では、Go と併せて HTMX をどのように使用しているかを解説します。主眼は Go の実装であり、HTMX の動作原理については簡単に触れさせていただきます。
対象となるトピック
- HTML テンプレートの構造化パターン
- Go で部分 HTML および全ページの HTTP レスポンスを返す方法
- リダイレクトとエラーの管理
- HTMX 特有の挙動に対する適切な対応策
- 標準的な HTMX 設定オプション
- その実装理由について詳しく説明
これらを実践するために、最終的にユーザー一覧に対してフィルタリング機能を付与する小さなアプリケーションの実装例を取り上げています。
1. プロジェクトの初期化
プロジェクトの骨組みを作成してください。以下のコマンドを実行します。
$ 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
これにより、以下のファイルツリーが生成されます:
. ├── assets │ ├── efs.go │ ├── html │ │ ├── base.tmpl │ │ ├── pages │ │ │ └── home.tmpl │ │ └── partials │ │ └── images.tmpl │ └── static │ ├── css │ ├── img │ └── js ├── cmd │ └── web │ ├── handlers.go │ ├── html.go │ └── main.go └── go.mod
2. HTMX のインストール方法
CDN から読み込むか NPM でインストールするのではなく、私はファイルをダウンロードして 静的ファイルとして配信しています。これによりシンプルかつ CDN のデメリットを避けられます。
また、CSS フレームワークの Bamboo と gophers イメージも同様にダウンロードします。以下のコマンドで
assets/static フォルダへ保存してください。
$ wget -P assets/static/js https://cdn.jsdelivr.net/npm/[email protected]/dist/htmx.min.js $ wget -P assets/static/css https://cdn.jsdelivr.net/npm/[email protected]/dist/bamboo.min.css $ wget -O assets/static/img/gopher.png https://raw.githubusercontent.com/egonelbre/gophers/refs/heads/master/sketch/misc/standing-left.png
インストール後のファイル構成
assets/static フォルダは以下の構造になります:
assets/static ├── css │ └── bamboo.min.css ├── img │ └── gopher.png └── js └── htmx.min.js
3. HTML テンプレートの構築
プロジェクト骨組みの設置が完了しました。ここからは HTML テンプレートについて解説します。
assets/html ディレクトリは以下の構造になっています:
- base.tmpl: すべてのウェブページで共通する「レイアウト」マークアップ
- pages/: 個別のウェブページ固有のコンテンツ
- partials/: 再利用可能な HTML マークアップチャンク(部分テンプレート)
base.tmpl に以下のマークアップを追加します。
base.tmpl の実装例
File: assets/html/base.tmpl {{define "base"}} <!doctype html> <html lang='en'> <head> <meta charset='utf-8'> <title>{{template "page:title" .}}</title> <meta name="viewport" content="width=device-width, initial-scale=1"> <!-- 共通アセット --> <link rel="stylesheet" href="/static/css/bamboo.min.css"> <script defer src="/static/js/htmx.min.js"></script> </head> <body> <h1><a href="/">Example website</a></h1> <main> {{template "page:content" .}} </main> </body> </html> {{end}}
実装のポイント
属性の使用defer
を使うことで、ブラウザが HTML パース中に並列に HTMX をフェッチしますが、HTML のパース完了後(DOM 構築後)にスクリプトを実行します。defer
- 明確な定義 (
){{define}}...{{end}}- ファイル単一のテンプレートでも明示的な名前を付けています。Go コードから常に名前で参照でき、ミスマッチを防ぐためです。
- 動的なコンテンツ注入
など、適切な場所に特定の内容を注入します。{{template "page:title" .}}
ページ固有のコンテンツ (home.tmpl
)
home.tmplFile: assets/html/pages/home.tmpl {{define "page:title"}}Home{{end}} {{define "page:content"}} <!-- HTMX 属性を持つボタン --> <button hx-get="/gopher" hx-swap="outerHTML"> Wanna see a cute gopher? </button> {{end}}
- 動作: ボタンクリック時に
に GET リクエストを送信し、レスポンスの HTML でボタン要素を置換します(/gopher
)。hx-swap="outerHTML"
パーシャルテンプレート (images.tmpl
)
images.tmpl画像を表示する汎用的な部分テンプレートを定義します。動的に幅を設定できるように
{{.}} を使用します。
File: assets/html/partials/images.tmpl {{define "partial:image:gopher"}} <img alt="Gopher" src="/static/img/gopher.png" width="{{.}}"> {{end}}
4. アセットの EMBED ディング (Go 1.16+)
HTML ファイルと静的アセットをディスクからではなく、**Go バイナリに
embed**します。これにより配布とデプロイが容易になります。
エMBED 処理 (assets/efs.go
)
assets/efs.goFile: assets/efs.go package assets import ( "embed" "io/fs" ) //go:embed "html" "static" var files embed.FS var ( HTMLFiles = sub(files, "html") StaticFiles = sub(files, "static") ) func sub(f embed.FS, dir string) fs.FS { sub, err := fs.Sub(f, dir) if err != nil { panic(err) } return sub }
HTML テンプレートレンダリング (cmd/web/html.go
)
cmd/web/html.go共有テンプレートセットをパースし、HTTP レスポンスとして出力するためのレンダラを作成します。
File: cmd/web/html.go package main import ( "bytes" "html/template" "io/fs" "net/http" "time" ) type htmlRenderer struct { templateFS fs.FS sharedTemplates *template.Template } // newHTMLRenderer: 共有テンプレートセットとカスタム関数を作成 func newHTMLRenderer(templateFS fs.FS, sharedTemplateFiles ...string) (*htmlRenderer, error) { funcs := template.FuncMap{ "now": time.Now, } sharedTemplates, err := template.New("").Funcs(funcs).ParseFS(templateFS, sharedTemplateFiles...) if err != nil { return nil, err } r := &htmlRenderer{ templateFS: templateFS, sharedTemplates: sharedTemplates, } return r, nil } // render: 共有テンプレートをクローンし、追加のテンプレートを実行してレスポンスへ書き出す func (h *htmlRenderer) render(w http.ResponseWriter, status int, data any, templateName string, additionalTemplateFiles ...string) error { ts, err := h.sharedTemplates.Clone() if err != nil { return err } // 追加テンプレート(Partial など)をパース if len(additionalTemplateFiles) > 0 { ts, err = ts.ParseFS(h.templateFS, additionalTemplateFiles...) if err != nil { return err } } buf := new(bytes.Buffer) err = ts.ExecuteTemplate(buf, templateName, data) if err != nil { return err } w.WriteHeader(status) buf.WriteTo(w) return nil }
アプリケーションの起動 (cmd/web/main.go
)
cmd/web/main.goファイルサーバーとルートを設定します。
File: cmd/web/main.go package main import ( "log/slog" "net/http" "os" "example.com/htmx/assets" ) type application struct { logger *slog.Logger html *htmlRenderer } func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) // 初期化:ベーステンプレートと全パッチを共有セットに読み込み htmlRenderer, err := newHTMLRenderer(assets.HTMLFiles, "base.tmpl", "partials/*.tmpl") if err != nil { logger.Error(err.Error()) os.Exit(1) } app := &application{ logger: logger, html: htmlRenderer, } // 静的ファイルサーバーの作成 fileserver := http.FileServerFS(assets.StaticFiles) // ルートの登録 mux := http.NewServeMux() mux.Handle("GET /static/", http.StripPrefix("/static", fileserver)) mux.HandleFunc("GET /{$}", app.home) logger.Info("starting server", "port", 5051) err = http.ListenAndServe(":5051", mux) if err != nil { logger.Error(err.Error()) os.Exit(1) } }
5. より複雑な実装例:ユーザー一覧とフィルタリング
インタラクティブ性を高めるために、ユーザーリストにフィルタリング機能を追加します。これには以下のルートが必要です:
: ユーザー全一覧を表示GET /users
: 検索キーワードに一致するユーザーの一覧(部分テンプレート)を返すGET /users/search
ページの追加 (assets/html/pages/users.tmpl
)
assets/html/pages/users.tmpl$ touch assets/html/pages/users.tmpl
File: assets/html/pages/users.tmpl {{define "page:title"}}Users{{end}} {{define "page:content"}} <input type="search" name="query" placeholder="Begin Typing To Search Users..." hx-get="/users/search" hx-trigger="input changed delay:500ms, keyup[key=='Enter']" hx-target="#search-results" hx-push-url="true"> <table> <thead> <tr> <th>Name</th> <th>Email</th> <th> </th> </tr> </thead> <tbody id="search-results"> {{template "users:rows" .}} </tbody> </table> {{end}} <!-- Fragment for rendering rows --> {{define "users:rows"}} {{range .}} <tr> <td>{{ .Name }}</td> <td>{{ .Email }}</td> <td> {{if .IsGopher}} {{template "partial:image:gopher" 24}} {{end}} </td> </tr> {{end}} {{end}}
ハンドラーの実装 (cmd/web/handlers.go
)
cmd/web/handlers.goFile: cmd/web/handlers.go package main import ( "net/http" "strings" ) type user struct { Name string Email string IsGopher bool } var users = []user{ {"Alice Madsen", "[email protected]", true}, {"Theo Thatcher", "[email protected]", true}, // ... 他のユーザーデータ } func (app *application) listUsers(w http.ResponseWriter, r *http.Request) { // 全ユーザーを含む完全な HTML ページを返す err := app.html.render(w, 200, users, "base", "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } func (app *application) searchUsers(w http.ResponseWriter, r *http.Request) { query := r.FormValue("query") var matches []user if query == "" { matches = users } else { for _, u := range users { if strings.Contains(u.Name, query) || strings.Contains(u.Email, query) { matches = append(matches, u) } } } // HTMX からくる場合:部分テンプレート(行のみ)を返す // 直接アクセスの場合:完全な HTML ページを返す if isHTMXRequest(r) { err := app.html.render(w, 200, matches, "users:rows", "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } else { // HTMX でない場合は完全ページを返す必要があるが、render のロジックで調整される err := app.html.render(w, 200, matches, "base", "pages/users.tmpl") if err != nil { app.logger.Error(err.Error()) http.Error(w, http.StatusText(500), 500) } } } // 簡易ヘルパー関数(isHTMXRequest の実装は後述) func isHTMXRequest(r *http.Request) bool { return r.Header.Get("HX-Request") == "true" }
6. HTMX の重要設定事項
キャッシュの管理とバックボタンの挙動
HTMX は URL を変更してレスポンスを置換すると、ローカルストレージに HTML をキャッシュします(デフォルト 10 ページ)。キャッシュ切れ時にサーバーへリクエストを送りますが、その際
HX-Request: true ヘッダーが含まれてしまい、部分テンプレートが返されてしまう可能性があります。
これを防ぐため、HTMX 設定で
historyRestoreAsHxRequest を false に設定します。
File: assets/html/base.tmpl <meta name="htmx-config" content='{ "historyRestoreAsHxRequest": false }'/>
- 理由: キャッシュミス時に HTMX は「通常のリクエスト(ヘッダーなし)」として送られ、アプリケーション側で完全な HTML ページを返すためです。
リダイレクトの管理
HTMX からのフォーム送信後などにフルページへリダイレクトする必要がありますが、通常の 3xx ステータスコードでは動作しません(ブラウザが HTMX の介入前に Intercept するため)。
以下のヘッダーを送信することで対応します:
: フルページリロードして URL に遷移させる。HX-Redirect: URL
: ブラウザ履歴にエントリを追加しながら、AJAX でフェッチした HTML を置換する(SPA 的体験)。HX-Location: URL
通常は安全で単純な
を使用するのが推奨されます。HX-Redirect
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 } // HTMX でない場合は通常のリダイレクト http.Redirect(w, r, url, code) }
エラーの管理 (4xx / 5xx)
HTMX のデフォルトでは、エラー(4xx/5xx)は DOM を置換せずコンソールにログを表示します。ユーザーへエラーを伝達する必要がある場合の設定です。
設定 (htmx-config
)
htmx-config<meta name="htmx-config" content='{ "responseHandling":[ {"code":"204", "swap": false}, {"code":"422", "swap": true, "target": "body"}, // フォーム検証エラー用 {"code":"[45]..", "swap": true, "target": "body"}, // 4xx/5xx を body 全体に置換 {"code":"...", "swap": true} // それ以外は通常ターゲットへ ] }'/>
実装例 (cmd/web/handlers.go
)
cmd/web/handlers.go意図的にエラーを返すテスト用ハンドラー:
func (app *application) gopher(w http.ResponseWriter, r *http.Request) { // テンプレートを誤って 500 エラーのメッセージを含むものに変更 err := app.html.render(w, http.StatusOK, 100, "partial:image:missing") // ... エラーハンドリングロジック }
これにより、設定通り
body ターゲットにエラーメッセージが置換されます。
ブラウザの現在 URL の取得
AJAX レクエスト時、Go ハンドラ側の
r.URL とブラウザの表示される URL が一致しない場合があります。HTMX は HX-Current-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-config
)
htmx-config以下のような設定が一般的で安全な初期値になります。
<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 }'/>
ページ固有のレイアウトへの拡張
必要に応じて、ベーステンプレート内の
<body> に特定の「レイアウト」テンプレートを読み込むことができます。
File: assets/html/base.tmpl <body> {{template "layout" .}} <!-- layout が省略されるか明示的に指定 --> </body>
File: assets/html/layouts/admin.tmpl {{define "layout"}} <h1><a href="/">Admin area</a></h1> <nav> <a href="/admin/users">Users</a> <a href="/admin/orders">Orders</a> </nav> <main> {{template "page:content" .}} </main> {{end}}
Go ハンドラでは
render 関数へ追加でレイアウトファイル名を渡します。
app.html.render(w, 200, nil, "base", "layouts/admin.tmpl", "pages/admin-orders.tmpl")