Vibe Gallery/ビハインドシーン

Vibe Gallery を支える技術選定と設計の信念

Vibe Gallery は「vibe coding ワークフローから生まれた成果物を共有する場所」を目指して作っているプラットフォームです。最初はアプリの投稿だけですが、いずれ記事や実験などあらゆるアウトプットの受け皿にしていきます。

このビハインドシーンでは、何を選び、何を選ばなかったか / なぜそうしたか を整理します。


1. 技術スタック

レイヤ採用主な代替
FrameworkNext.js 16 App Router (RSC)Remix / SPA
LanguageTypeScript
UITailwind CSS + shadcn/ui (Radix primitives)MUI / Chakra
ORM / DBDrizzle ORM + PostgreSQLPrisma / Supabase client
AuthClerk (@clerk/nextjs)NextAuth / 自前
StorageS3 互換 (本番 AWS / ローカル rustfs)Vercel Blob / Supabase Storage
Validationzodyup / valibot
Testvitest + React Testing Libraryjest
DeployVercelCloudflare / 自前
AI IntegrationMCP (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/servergetTranslations() は 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/
  • domainApp / AITool / User などの DB エンティティを props に取る部品

そして「関心の単位」原則として、hooks/ utils/ types/ のような 技術カテゴリ別の千切り を禁止しています。 代わりに関心ごとにディレクトリを切り、その中に auth.ts use-auth.ts types.ts を同居させます。

参考:


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 9728
  • GET /.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_appthumbnailStorageKey を渡す。MCP と REST で同じ presign ヘルパを共有

6. ストレージ: polymorphic attachments と rustfs

サムネイル管理は紆余曲折を経て今の形になりました。

  1. はじめは apps.thumbnail_url をテーブルに直保持
  2. polymorphic な attachments テーブルに分離(PR でリンクを切り、孤児 blob を transaction で掃除)
  3. 最後に apps_attachments / behind_scenes_attachmentsrecord_type 別テーブルに分割(一見 polymorphic から後退に見えるが、外部キー制約が張れて整合性が増す)

ローカル開発では rustfsdocker 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 が labels props に組み立ててから渡す
  • hooks/ utils/ types/ で技術カテゴリ別にファイルを集めない … 関心の単位でディレクトリを切る

まとめ

Vibe Gallery の設計は、ひとことで言えば 「境界を機械で守る」 ことに尽きます。

  • 再帰的 Features 構造で「どこに置くか」を機械で解く
  • ESLint で components の純粋性を機械で守る
  • ルートグループでオーナー権限を構造的に守る
  • Drizzle + repositories でクエリの境界を守る
  • MCP + OAuth で AI クライアントとの境界を守る

この記事自体も MCP 経由で投稿されました。 ぜひ https://vibe-gallery.space/api/mcp を Claude Desktop / Cursor / Cline から繋いで、自分のアプリやビハインドシーン記事を投稿してみてください。

Vibe Gallery を支える技術選定と設計の信念 - Vibe Gallery - Vibe Gallery | Vibe Gallery