Vibe Gallery を支える技術選定と設計の信念
Vibe Gallery は「vibe coding ワークフローから生まれた成果物を共有する場所」を目指して作っているプラットフォームです。最初はアプリの投稿だけですが、いずれ記事や実験などあらゆるアウトプットの受け皿にしていきます。
このビハインドシーンでは、何を選び、何を選ばなかったか / なぜそうしたか を整理します。
1. 技術スタック
| レイヤ | 採用 | 主な代替 |
|---|---|---|
| Framework | Next.js 16 App Router (RSC) | Remix / SPA |
| Language | TypeScript | — |
| UI | Tailwind CSS + shadcn/ui (Radix primitives) | MUI / Chakra |
| ORM / DB | Drizzle ORM + PostgreSQL | Prisma / Supabase client |
| Auth | Clerk (@clerk/nextjs) | NextAuth / 自前 |
| Storage | S3 互換 (本番 AWS / ローカル rustfs) | Vercel Blob / Supabase Storage |
| Validation | zod | yup / valibot |
| Test | vitest + React Testing Library | jest |
| Deploy | Vercel | Cloudflare / 自前 |
| AI Integration | MCP (Vercel mcp-handler + Clerk @clerk/mcp-tools) | 独自 REST のみ |
「枯れていて Vercel 上で素直に動くもの」を基準に選んでいます。 派手に新しい技術を入れるより、コーディングそのもの (vibe coding) に集中できる土台 を優先します。
2. 信念: アーキテクチャを支える 5 つの原則
2-1. 再帰的 Features 構造
features/<Name>/ を単位にして、
index.tsxだけが公開境界- API/データ取得は feature の
index.tsx(Server Component) に集約 - pure な部品は
components/配下に - ある feature の中でしか使われない feature は親 feature の
features/配下にネスト
例:
features/
├── AppDetail/
│ ├── index.tsx
│ ├── components/
│ │ └── AppDetailHeader/
│ └── features/
│ └── BehindScenes/
│ └── index.tsx
参考: https://zenn.dev/pksha/articles/recursive-features-directory-structure
ある feature が複数の親から参照され始めた瞬間に 1 段上に引き上げる。 これだけのルールで「どこに置くか問題」のほぼ全部が機械的に解ける。
2-2. page は薄いシェル
app/.../page.tsx でやっていいのは、
- auth ガード(
getCurrentUser()→redirect("/login")) - params の解決
- feature の呼び出し
だけです。ページに fetch ロジックや JSX 組み立てを書きません。 これにより「URL = feature の入口」が一対一で見える状態を保てます。
2-3. mutation は Server Actions ではなく API Routes
新機能の mutation は基本的に app/api/*/route.ts に置きます。理由:
- エンドポイントが URL として明示される(cURL でも MCP からでも叩ける)
- Server Action 限定の制約(フォーム送信前提・ストリーム不可など)に縛られない
- MCP ツールとの責務の重なり が起きにくい
Server Actions が悪いというより、「fetch 一本で叩けるエンドポイントの方が、外部 (MCP / 将来のモバイル / 他サービス) に開きやすい」という選択です。
2-4. DB クエリは repositories に集約
shared/repositories/* を唯一のクエリの置き場所とし、API Route や feature から直接 db.select().from(...) を書かないようにしています。
- 命名は Rails の
find_by風 (findAppById/findAppByIdOrThrowなど) - 書き込み・transaction も同じ層に集める(PR #97 で attachments / apps / behind-scenes を一気に統一)
- これによりテストでクエリを差し替えやすく、N+1 や同等クエリの重複を見つけやすくなる
2-5. components の純粋性は ESLint で機械強制
shared/components/** と features/**/components/** から以下の import は ESLint で禁止しています。
| 禁止 import | 理由 |
|---|---|
@/shared/i18n/server | getTranslations() は async + server-only。component を async にしない |
@/shared/auth/auth | 認証は feature の責務 |
@/shared/repositories | データ取得は feature の責務 |
@clerk/nextjs/server | サーバ認証は feature の責務 |
「ルールがあるが守られない」を避けたいので、人間のレビューに頼らず npm run lint で落ちるようにしてあります。
3. ディレクトリ命名: BCD と「関心の単位」
shared/components/ は BCD Design (base / case / domain) に分けています。
- base … shadcn primitives 相当。ドメイン知識ゼロ。フラット配置 (
button.tsx,card.tsx) - case … 汎用 composite。vibe-gallery 固有エンティティを知らない(
theme/,markdown/,copy-button/) - domain …
App/AITool/Userなどの DB エンティティを props に取る部品
そして「関心の単位」原則として、hooks/ utils/ types/ のような 技術カテゴリ別の千切り を禁止しています。
代わりに関心ごとにディレクトリを切り、その中に auth.ts use-auth.ts types.ts を同居させます。
参考:
- https://zenn.dev/misuken/articles/c335d978bed41a(BCD Design)
- https://zenn.dev/misuken/articles/bdd33790ed4cd0(関心の単位)
4. ルーティング: スコープ × 用途の 2 階層 route group
app/ は URL に影響しない route group で 2 階層に切っています。
app/
├── (public)/
│ ├── (managed)/ # 運営管理ページ。Navbar + Footer
│ └── (content)/ # ユーザーコンテンツ。Navbar のみ
└── (private)/
├── (account)/ # ログイン後のアカウント・設定
└── (workspace)/ # 編集 UI。EditHeader(保存・status)
ここで効いている運用ルールが 「公開ページにオーナー専用アクションを置かない」 です。
isOwner で出し分けても、公開 /apps/[id] に「edit」「manage」などを出すのを禁止し、編集は必ず (private)/(workspace) 配下のオーナー専用ルートに置きます。
公開ページと編集ページが「同一 URL の出し分け」になっていないので、権限のバグでオーナーアクションが他人に漏れる事故が構造的に発生しない。これは単なる UX ルールではなく、セキュリティの不変条件として運用しています。
5. MCP を一級市民として作る
Vibe Gallery は最初から MCP (Model Context Protocol) 対応 を前提に設計しました。 Claude Desktop / Cursor / Cline などから直接、ギャラリーを閲覧・投稿・編集・削除できます。
構成
POST /api/mcp— Streamable HTTP transport (推奨)GET /api/sse— SSE transport (legacy 互換)GET /.well-known/oauth-protected-resource— RFC 9728GET /.well-known/oauth-authorization-server— RFC 8414 (Clerk を参照)
実装は Vercel の mcp-handler と Clerk の @clerk/mcp-tools を組み合わせています。
読み取りも書き込みも全ツールを Clerk OAuth 2.1 (PKCE + Dynamic Client Registration) で保護 しているのが特徴です。トークンなしでアクセスすると withMcpAuth({ required: true }) が 401 + WWW-Authenticate: Bearer resource_metadata="…" を返し、それをトリガに MCP クライアントが OAuth フロー(DCR + ブラウザサインイン)を自動で開始します。
「読み取りは public でいいのでは?」と最初は思いましたが、
get_meのような相対 API のためにも全ツールを認証必須にする方がクライアント実装がシンプルになりました(PR #94)。
ツール一覧
読み取り: list_apps / get_app / list_user_apps / list_ai_tools / list_behind_scenes / get_behind_scenes / get_site_info
書き込み: get_me / create_upload_url / submit_app / update_app / delete_app / reverify_app / create_behind_scenes / update_behind_scenes / delete_behind_scenes
そして このビハインドシーン記事自体、create_behind_scenes ツール経由で書かれています。 ドッグフーディング。
MCP の細部のこだわり
- zod 駆動でツールアノテーションを自動付与。
destructiveHint/idempotentHint/openWorldHintを schema から導出し、16 ツール全件に整合性を持たせる submit_appのレスポンスに X share intent URL を含める。「投稿したらすぐシェアまで案内する」体験を MCP からも 1 ステップで実現- 画像アップロードは presign + 2 段階フロー:
create_upload_url→ S3 へ直 PUT →submit_app/update_appにthumbnailStorageKeyを渡す。MCP と REST で同じ presign ヘルパを共有
6. ストレージ: polymorphic attachments と rustfs
サムネイル管理は紆余曲折を経て今の形になりました。
- はじめは
apps.thumbnail_urlをテーブルに直保持 - polymorphic な
attachmentsテーブルに分離(PR でリンクを切り、孤児 blob を transaction で掃除) - 最後に
apps_attachments/behind_scenes_attachmentsの record_type 別テーブルに分割(一見 polymorphic から後退に見えるが、外部キー制約が張れて整合性が増す)
ローカル開発では rustfs を docker compose で立ち上げ、本番 S3 と同じ API を叩きます。
# docker-compose.yml の rustfs
# - http://localhost:9000 S3 API
# - http://localhost:9001 Web コンソール
# - rustfs-init が `vibe-gallery-dev` バケットを作って公開 GET も有効化
これで「ローカルだけ別実装」というブランチが完全に消え、STORAGE_DRIVER=s3 のまま開発できるようになりました。
7. AI を巻き込んだ開発フロー
Vibe Gallery 自体が「vibe coding で作られたサービスを集める場所」なので、開発フローも AI 前提です。
- Claude Code Web で feature ブランチを切って実装
- Claude PR Assistant + Claude Code Review workflow を GitHub Actions に常駐させ、レビューは rubric ベースで approve まで自動的に進む
llms.txt/llms_full.txtをサイトと記事から配信。LLM 側がこのサイトを引用するとき、必要十分なコンテキストだけを渡せるよう 3 階層に整理
CLAUDE.md にプロジェクトルール、.claude/rules/frontend.md にフロントエンド規約を置いて、AI もレビュアも同じテキストを読んで判断するようにしてあります。
「人間にだけ読みやすいルール」と「AI にだけ読みやすいルール」を分けない のが運用上のコツでした。
8. やらなかったこと(明示的な NO)
最後に、よく聞かれるが採用していないものを置いておきます。
- Server Actions を mutation のメイン経路にしない … API Routes に統一
- 匿名投稿モードをやらない … 認証必須。所有者と紐付かないコンテンツは責任の所在が消える
<img>直書きを許さない …next/image必須。@next/next/no-img-elementを ESLint で有効化したまま(private)/layout.tsxで auth を集約しない … page ごとにgetCurrentUser()を明示的に呼ぶ。layout 越しの暗黙ガードはリファクタで簡単に外れる- i18n の
getTranslations()を component から呼ばない … 親 feature がlabelsprops に組み立ててから渡す hooks/utils/types/で技術カテゴリ別にファイルを集めない … 関心の単位でディレクトリを切る
まとめ
Vibe Gallery の設計は、ひとことで言えば 「境界を機械で守る」 ことに尽きます。
- 再帰的 Features 構造で「どこに置くか」を機械で解く
- ESLint で components の純粋性を機械で守る
- ルートグループでオーナー権限を構造的に守る
- Drizzle + repositories でクエリの境界を守る
- MCP + OAuth で AI クライアントとの境界を守る
この記事自体も MCP 経由で投稿されました。
ぜひ https://vibe-gallery.space/api/mcp を Claude Desktop / Cursor / Cline から繋いで、自分のアプリやビハインドシーン記事を投稿してみてください。
