Go templ + htmx で構築するモダン Web アプリ(2026)
はじめに
10 年間 SPA が支配してきたが、2026 年チームは再考中:管理画面・社内ツールの 90% はクライアントルーティング不要。SEO と初回表示速度が命。フルスタックは同一言語で。Go の templ + htmx はこのための技術:コンパイル時型安全テンプレート + HTML 属性駆動インタラクション——JS 不要のレスポンシブ Web。
templ:型安全 Go テンプレート
html/template は実行時文字列解析——タイポはレンダリング時まで発見不能。templ は Go コードにコンパイル——全エラーをコンパイル時検出。
package views
templ Header(title string) { <header><h1>{ title }</h1><nav><a href="/">Home</a></nav></header> }
templ Layout(title string) {
<!DOCTYPE html><html><head><script src="https://unpkg.com/htmx.org@2.0.4"></script></head>
<body>@Header(title)<main>{ children... }</main></body></html>
}
{ title } 型チェック。{ children... } スロット。コンポーネント間呼出はゼロランタイムオーバーヘッド——コンパイル時インライン化。
htmx:ハイパーメディア駆動
「任意の HTML 要素が HTTP リクエスト発行しレスポンスでページ任意箇所を置換」——JS なし。
| 属性 | 目的 | 例 |
|---|---|---|
hx-post/get |
HTTP トリガー | hx-post="/submit" |
hx-target |
対象要素 | hx-target="#result" |
hx-swap |
置換モード | hx-swap="outerHTML" |
hx-trigger |
トリガー | hx-trigger="keyup changed delay:500ms" |
hx-indicator |
ローディング | hx-indicator="#spinner" |
ライブ検索
templ SearchPage() {
<input type="search" hx-get="/search" hx-trigger="keyup changed delay:300ms"
hx-target="#results" hx-indicator="#spinner"/>
<div id="spinner" class="htmx-indicator">読込中...</div>
<div id="results"></div>
}
func searchHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
views.SearchResults(searchDB(q)).Render(r.Context(), w)
}
結果断片のみ返却——htmx が #results に自動置換。
フォーム+インライン検証
templ CreateForm(errors map[string]string) {
<form hx-post="/create" hx-target="this" hx-swap="outerHTML">
<input type="text" name="title"/>
if errors["title"] != "" { <span class="error">{ errors["title"] }</span> }
<button>作成</button>
</form>
}
失敗時、同じフォーム断片をエラー付きで返却。hx-target="this" でその場置換——ポップアップ不要、JS不要。
完全実装:ブログ CRUD
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /posts", listPosts)
mux.HandleFunc("POST /posts", createPost)
mux.HandleFunc("PUT /posts/{id}", updatePost)
mux.HandleFunc("DELETE /posts/{id}", deletePost)
http.ListenAndServe(":8080", mux)
}
削除——JS ゼロ
templ PostCard(p Post) {
<div id={"post-"+p.ID} class="post-card">
<h2>{ p.Title }</h2>
<button hx-delete={fmt.Sprintf("/posts/%d",p.ID)}
hx-target={"#post-"+fmt.Sprintf("%d",p.ID)}
hx-swap="delete" hx-confirm="削除しますか?">削除</button>
</div>
}
hx-swap="delete" で対象要素を直接削除——ハンドラは 200 OK 返すだけ。
ハンドラ例
func createPost(w http.ResponseWriter, r *http.Request) {
title:=r.FormValue("title"); slug:=r.FormValue("slug")
errors:=validate(title,slug)
if len(errors)>0 { views.CreateForm(errors).Render(r.Context(),w); return }
post,_:=db.Insert(title,slug)
views.PostCard(post).Render(r.Context(),w)
}
SSE リアルタイム
templ LiveFeed() {
<div hx-sse="connect:/events"><div hx-sse="swap:message">待機中...</div></div>
}
func eventsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type","text/event-stream")
flusher:=w.(http.Flusher)
for msg:=range globalChan {
fmt.Fprintf(w,"event: message\ndata: <p>%s</p>\n\n",msg)
flusher.Flush()
}
}
Socket.IO 不要。WebSocket 管理不要。
templ + htmx vs React SPA
| 次元 | templ+htmx | React SPA |
|---|---|---|
| 初回描画 | サーバーHTML、白画面なし | SSR/SSG 必要 |
| SEO | 完璧 | 追加設定 |
| インタラクション | HTML 属性、JS 不要 | JSX+hooks |
| 状態管理 | サーバーが真実 | クライアント+同期 |
| ビルド | templ generate+go build |
Vite+TS |
| 成果物 | 単一バイナリ(~15MB) | 静的+Node/CDN |
| コールドスタート | ミリ秒 | SSR ランタイム依存 |
| チーム要件 | Goのみ | フロントエンド要 |
| アニメーション | Alpine.js(~15KB) | 豊富な生態系 |
中核差:templ+htmx = ハイパーメディア駆動(サーバーが中間状態をレンダリング、クライアントは部分置換のみ)。React = クライアントサイド(クライアントが状態所有)。
デプロイ
templ generate && go build -o myapp . && ./myapp
単一バイナリ。Docker は scratch から ~15MB。
パフォーマンス:レスポンスキャッシュ、静的アセット nginx/CDN、DB 接続プール。
FAQ
Q1:ファイルアップロード?——hx-encoding="multipart/form-data" + <input type="file">。
Q2:ドラッグ&ドロップ?——Alpine.js(~15KB)で局所処理、htmx はサーバー処理。
Q3:コンポーネント多すぎ?——200+でも templ generate ~1.5秒 (M3 Pro)。
Q4:フロントエンド協業?——views/ を渡す。templ 構文は JSX に近い。
結論
SPA の「代替」ではない——異なるパラダイム。状態はサーバー、インタラクションは HTML。アプリが「表示+フォーム+検索」なら、半分のコード量でより良いパフォーマンス。
学習パス:カウンター→hx-post→フォーム検証→検索→デプロイ。