← 一覧へ戻る

Go バックエンド + Vue 3 フロントの最小構成テンプレート

Go API と Vue 3 SPA を 1 つの single binary に畳む最小構成。ディレクトリ・認証・デプロイまで、派生 SaaS を立てるたびに使い回している雛形を晒す。

派生 SaaS を立てるたびに、同じ骨を組み直している

nadai では gpdf の上に SaaS 層 (gpdf-app / gpdf-cloud) を作り始めていて、そこで気づいたのは「最初の 1 日が毎回もったいない」ことだった。Go の HTTP サーバを立てて、Vite で Vue 3 を起こして、ルーティングと認証の骨を入れて、デプロイ導線を作る。中身はプロダクトごとに違うのに、この骨組みだけは毎回ほぼ同じだ。

なので最小テンプレートとして固めた。nadai で並走させている派生プロダクトはだいたいこの形から始めている。軸は 3 つ — フレームワークを足さない、依存を増やさない、最終成果物を 1 つの single binary にする。実際の構成をそのまま書く。

ディレクトリ構造

myapp/
├── main.go
├── internal/
│   ├── server/        # http.Handler の組み立て、ミドルウェア
│   ├── auth/          # cookie セッション
│   └── store/         # DB アクセス (gsql を使うことが多い)
├── web/               # 独立した Vite プロジェクト
│   └── src/{main.ts, App.vue, router.ts, pages/}
└── web/dist/          # vite build の出力。go:embed で取り込む

Go 側と Vue 側を 1 リポジトリに同居させ、web/dist を Go が embed する。monorepo ツールは入れない。ビルドは Makefile の数行だ。

Go 側: 標準ライブラリで足りる

Echo も Gin も chi も使っていない。Go 1.22 で net/http.ServeMux がメソッドとパスパラメータ (GET /api/users/{id}) を扱えるようになって、個人開発規模のルーティングは標準ライブラリで完結する。ルータを 1 つ削れると依存ツリーが一段浅くなる。これは single binary 配布が個人開発者に有利な理由 で書いた「監査範囲を狭く保つ」の延長線上にある。

//go:embed all:web/dist
var assets embed.FS

func main() {
	dist, _ := fs.Sub(assets, "web/dist")

	mux := http.NewServeMux()
	server.Mount(mux)                              // /api/* を登録
	mux.Handle("/", server.SPA(http.FS(dist)))     // それ以外は SPA に流す

	log.Fatal(http.ListenAndServe(":8080", mux))
}

server.SPA は「ファイルがあれば返す、無ければ index.html を返す」だけのハンドラだ。http.FileServer をラップして fsys.Open が失敗したら r.URL.Path = "/" に書き換える、それだけ。Vue Router の history モードで /dashboard に直アクセスされても、サーバが index.html を返せばクライアント側のルータが拾う。

ミドルウェアは最初は「リクエストログ」「panic recover」「セッション読み取り」の 3 つだけ。CORS は同一オリジン配信なので要らない — front と API を別ドメインにしないと決めたことの副産物だった。レート制限や監視はトラフィックが来てから足せばいい。DB は型安全な gsql を使うことが多く、これも Pure Go なので single binary に乗る。

Vue 3 と認証: cookie セッションで十分

フロントの依存は vuevue-routerpiniavite だけ。UI ライブラリ (Vuetify / Element Plus) は入れない — コンポーネントライブラリは「あとから剥がしにくい依存」の代表で、初期の薄いプロダクトには重すぎる。vite.config.ts は dev だけ /api を Go にプロキシする:

export default defineConfig({
  plugins: [vue()],
  build: { outDir: 'dist' },
  server: { proxy: { '/api': 'http://localhost:8080' } },
})

npm run dev で Vite が :5173、Go が :8080。本番では Go が web/dist を配信するので、この proxy は dev 専用だ。API クライアントは fetch のラッパ 1 ファイル、axios も入れない。piniauseAuthStore を 1 つ置いてログイン状態を持つ。

認証は JWT を使っていない。HttpOnly + Secure + SameSite=Lax の cookie に opaque なセッション ID を入れ、サーバ側 (最初は SQLite のテーブル、規模が出たら Redis) で引く。JWT のトークン失効問題 (ログアウトしてもトークン自体は生きている) を個人開発の段階で抱えたくないからだ。セッションテーブルなら DELETE 一発でログアウトが完結する。ミドルウェアで r.Context() にユーザーを詰め、各 API は auth.From(ctx) で取り出す。Vue 側は起動時に /api/me を 1 回叩いて、200 ならログイン済み、401 ならログイン画面へ。これで「リロードしても状態が保たれる SPA」になる。

デプロイ: ビルドして scp して終わり

build:
	cd web && npm ci && npm run build
	go build -o myapp .

deploy: build
	scp myapp server:/opt/myapp/myapp && ssh server systemctl restart myapp

go build の時点で web/distembed 済みなので、サーバに置くのは myapp 1 ファイルだけ。systemd の unit を 1 つ書いて Caddy で TLS を終端する。Docker なら FROM gcr.io/distroless/static + COPY myapp / で数 MB のイメージだ。このデプロイの軽さが、nadai で複数の派生プロダクトを並走させられる前提になっている。各プロダクトの運用が「ファイル 1 個とプロセス 1 個」に収まるので、頭の中の在庫管理が破綻しない。全体像は nadai ecosystem: 複数 micro SaaS を一人で運営する戦略 に書いた。

これは何ではないか

この構成は「SEO が要るマーケサイト」には向かない。SPA なので初期描画が JS 依存で、OGP の動的生成も面倒だ。LP やブログが重要なら、そこは別途 Nuxt や Astro で SSR / SSG する。実際 nadai 本体は Nuxt で、この SPA テンプレートとは別系統 — アプリ本体 (ログインの向こう側) は SPA、入口は SSR、という住み分けだ。

省いたものも多い。マイグレーション、メール送信、決済、バックグラウンドジョブ。プロダクトによって要否が変わるし、入れた瞬間に「最小」ではなくなる。決済は特に毎回悩んでいて、Stripe を Go から叩く薄いラッパを使い回したいのだが、課金モデルがプロダクトごとに違いすぎて、まだ「これ」という形に落とせていない。たぶん次の派生でもまた一から書くことになる。

次のステップ

骨組みができたら、次はそのプロダクトで検証すべき仮説に集中する。何をどこで確かめるかは プロダクトリリース前のバリデーション戦略 に整理した。

テンプレートは固めすぎると足枷になる。今のところリポジトリのテンプレート機能で雛形を 1 つ持っておく程度に留めて、毎回少しずつ作り直している。標準ライブラリ縛りをいつまで続けるか、SQLite で粘る範囲はどこまでか — この辺はまだ揺れている。

© 2026 nadai · 日本Set in Instrument Serif · Inter