HTMX を Go で活用する方法

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/template
    パッケージによるサーバーサイド HTML レンダリングの一貫性と安全性を維持できる

この記事では、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}}

実装のポイント

  1. defer
    属性の使用
    • defer
      を使うことで、ブラウザが HTML パース中に並列に HTMX をフェッチしますが、HTML のパース完了後(DOM 構築後)にスクリプトを実行します。
  2. 明確な定義 (
    {{define}}...{{end}}
    )
    • ファイル単一のテンプレートでも明示的な名前を付けています。Go コードから常に名前で参照でき、ミスマッチを防ぐためです。
  3. 動的なコンテンツ注入
    • {{template "page:title" .}}
      など、適切な場所に特定の内容を注入します。

ページ固有のコンテンツ (
home.tmpl
)

File: 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}}
  • 動作: ボタンクリック時に
    /gopher
    に GET リクエストを送信し、レスポンスの HTML でボタン要素を置換します(
    hx-swap="outerHTML"
    )。

パーシャルテンプレート (
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
)

File: 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
)

共有テンプレートセットをパースし、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
)

ファイルサーバーとルートを設定します。

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
)

$ 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>&nbsp;</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
)

File: 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 するため)。

以下のヘッダーを送信することで対応します:

  • HX-Redirect: URL
    : フルページリロードして URL に遷移させる。
  • HX-Location: URL
    : ブラウザ履歴にエントリを追加しながら、AJAX でフェッチした HTML を置換する(SPA 的体験)。

通常は安全で単純な

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
)

<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
)

意図的にエラーを返すテスト用ハンドラー:

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
)

以下のような設定が一般的で安全な初期値になります。

<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")

同じ日のほかのニュース

一覧に戻る →

2026/07/15 2:50

bonsai27b:スマートフォンで動作する 270 億パラメータモデル

## Japanese Translation: PrismML は、Qwen3.6 を基盤とする画期的なマルチモーダル AI モデルである Bonsai 27B をリリースしました。これは持続可能かつオンデバイス的人工知能へと向かう転換点を示すものです。Bonsai はユニークな構造を採用し、ネットワーク全体で低ビット形式のみで動作します(高精度のエスケープハッチはありません)。これにより未曽有の極限圧縮を実現し、1 ビットバイナリ版は 3.9 GB、ターナリー版は 5.9 GB のサイズです。このブリークスルーにより、モデルは iPhone 17 Pro の約 6 GB というメモリ予算内に収めることが可能となり、従来の構築(16 ビットで約 54 GB、4 ビットで約 18 GB)とは対照的です。この圧縮にもかかわらず、Bonsai は重要な数学、コーディング、ツール呼び出しタスクにおいて、フル精度モデルの知能を 90%〜95% 維持しています。 エンドツーエンドの低ビットアーキテクチャとコンパクトな 4 ビットビジョンタワーを備えた Bonsai は、大規模なコンテキストウィンドウ(262K トークン)をサポートし、高速な推論を実現します(NVIDIA RTX 5090 では最大 163 トークン/秒)。これは標準モデルと比較してギガバイトあたり十倍以上の高い知能密度を提供することで、新たな産業基準を設定しています。この革新により、ユーザーはプライバシーを重視するエージェントワークロードをローカルで実行でき、ファイルや画面入力といったデータをオンデバイスに保持し、追加のコンピューティングコストなしで動作します。最も先進的なタスクがハイブリッド戦略によってクラウド接続を利用することもあるかもしれませんが、Bonsai は即座に Apple デバイス上のネイティブデプロイメント(MLX を通じて)および NVIDIA GPU 上のデプロイメント(CUDA を通じて)をサポートします。Apache 2.0 ライセンスの下でリリースされ、無料の開発者向けプレビュー API が提供されており、データ流出のリスクなしに高度なエージェントワークフローへのアクセス可能な経路を提供します。

2026/07/15 6:15

Dependabot のバージョン更新機能でデフォルトのパッケージ冷却期間が導入されます

## Japanese Translation: GitHub の Dependabot は、バージョン更新用のプルリクエストを自動的に作成する前に 3 日間の待機期間を導入する新たなセキュリティ対策を実装しています。この重要な変更は、パブリケーションの直後にマルウェア攻撃者がパッケージリリースを乗っ取るサプライチェーン攻撃からソフトウェアリポジトリを守ることを目的としています。これらのリクエストの作成を遅らせることで、管理者らはそのコードがビルドプロセスに投入される前に、潜在的に汚染されたコードを検査するための十分な時間を確保できます。重要なのは、この安全バッファは重要なセキュリティ更新には適用されない点です。セキュリティ更新は、不可欠な脆弱性パッチへの即時アクセスを確保するために待機期間を完全に迂回します。GitHub Enterprise Server のバージョン 3.23 以前を管理する組織は、後ほどアップグレードする必要があります。また、他の組織も `.github/dependabot.yml` ファイルを介して設定を即座に構成し、このウィンドウを調整または無効化できます。結論として、この更新により、堅牢なセキュリティ衛生と運用上の柔軟性のバランスが達成され、急速な悪意のあるデプロイのリスクは低減しつつ、必要な依存関係の改善は遮断されることなく対応されます。

2026/07/15 1:57

塔はしきりに高まる

## 日本語訳: 核心となる論点は、AI を支援したプログラミングは、チームがシステムアーキテクチャについての共有理解を失うにつれてソフトウェア開発が引き続き進むという危険なパラドックスを生み出すと主張している。創世記におけるバベルの塔に関する聖書の物語とは異なり、そこでは神は統率能力を取り除くことによる協調性を妨げ、建設を停止させるために共通言語を撤去したが、現代の AI ツールは無休憩の通訳として機能する。これらのエージェントは、チームとの即時的な調整が必要ないかシステム全体モデルを取得することなく、孤立した開発者がローカルな変更(例えば OAuth やキャッシングの追加など)を行うことを可能にする。 この人工知能への依存リスクは、「アーキテクチャ言語」——つまり、概念、境界線、不変条件、所有権、およびシステムの形状に関する集合的な精神地図——が人間の記憶から薄れ去る原因となり得る。以前では摩擦(コードを読むこと、質問すること)が人々を同期させるのに役立っていたが、現在はエージェントがこの摩擦を取り除くことで、共有理解が崩壊しながらも変更が適用されることを可能にしている。その結果、個人は特定の機能を効率的に管理する一方、システム全体はその健全性を保つために必要な集合的知識から切り離されてしまう。その結果、開発者がコンポーネントがどのように互いに組み合わさっているかを説明できなくなった後でも、重要な変更を施す状態が生じる。この変化は、複雑なソフトウェアが誰一人として大局的な含意を完全に理解していないまま進化する可能性のある将来の脆弱性を生み出す恐れがあり、それはブルーゲルによる協力失敗を描いた作品に似た混沌としたプロジェクトにつながりうる。

HTMX を Go で活用する方法 | そっか~ニュース