miya8060
← back to blog

NOTE · 2026-05-07

kokan-nikki の技術スタック選定理由を全部書く

kokan-nikki (Next.js 16 / Auth.js v5 / Prisma 6 / Postgres 16 / zod 4 / Tailwind v4 / Vitest + Testcontainers / Playwright / Resend / Vercel + Neon) は、要件と制約から逆算してツールを選んだ。次に新しい Web アプリを 1 人で組むときに同じ判断をするか確認できるよう、各層で『何を求めて、何を捨てて、何を選んだか』を全部書き残す。

nextjsstackauthprismavitestplaywrightverceldesign-handoff

kokan-nikki (グループで回す Y2K カワイイ系の交換日記アプリ) を 1 人で組むときに、ツールスタックを真面目に選び直した。各層で「何を求めて、何を捨てて、何を選んだか」を残しておくと、次の Web アプリを作るときに同じ判断ができる (or 違う判断をするときに分岐点が見える) ので、スタックを丸ごと書き出す。

前提として、このアプリの制約は次の 4 つ:

  1. 1 人開発。レビュアー = 自分、運用 = 自分。判断負荷を下げる選択を優先。
  2. DB 密結合のドメインロジック (ターン制、招待の atomic claim、ナッジの 24h クールダウン)。型と DB をまとめて検証できないと壊れる。
  3. Vercel デプロイ前提。Edge / Node の境界、cron、preview 環境はプラットフォームの都合に従う。
  4. エージェント (Claude Code) と並走する開発。AGENTS.md / CLAUDE.md にルールを集約して、人間とエージェントが同じ前提で動く。

この 4 つを軸に、レイヤごとの選定を見ていく。

フレームワーク: Next.js 16 (App Router) + React 19

求めた条件:

  • Server Component と Server Actions を一級市民として書きたい (DB 操作の薄いラッパーを Express で書きたくない)
  • ファイルシステムベースのルーティング
  • Vercel との統合
  • 静的生成と動的生成の混在

候補は Remix (React Router v7) と Next.js 14 (Pages Router) と Next.js 16 (App Router) と SvelteKit。SvelteKit は React のエコシステムを捨てる代償が大きすぎる。Remix は Server Action 相当 (action) は強いが、Vercel の cron や preview を素のまま使うなら Next.js のほうが摩擦が少ない。Pages Router は型システムと Server Actions の組み合わせが App Router ほど洗練されていない。

選んだのは Next.js 16 (App Router) + React 19。Server Action から revalidatePath を呼ぶだけで cache 無効化が完結し、use server で関数 1 つを RPC エンドポイント化できる。型は zodsafeParse で入力を絞り込めば、TS の絞り込みが server / client の境界を越えて効く。

落とし穴:

  • 「これは知っている Next.js ではない」。AGENTS.md の冒頭に書いてあるように、API・規約・ファイル構成が訓練データの想定から外れている可能性が高い。実装前に node_modules/next/dist/docs/ を読む習慣をつけないと、エージェントが古い知識を引きずって getServerSideProps 相当を書こうとする。
  • error.tsx / not-found.tsx / global-error.tsx の三層構造 (別記事 Next.js 16 のエラーページ三層を別物として扱う)。一枚岩で書こうとすると詰む。
  • TS plugin rules/client-boundary.ts の whitelist が reset*Action で止まっていて、unstable_retry を直接 typed すると 71007 誤警告が出る。interface RetryFn { (): void } で interface 経由にすれば回避できる。

認証: Auth.js v5 (next-auth) + Resend Email Provider + Prisma Adapter + DB セッション

求めた条件:

  1. Magic link が一級市民。F-AUTH-02 で「パスワード機能は持たない」と決めたので、magic link を bolt-on で雑に足せるサービスは選べない。
  2. Prisma Adapter があること。スキーマを Prisma で管理しているので、auth テーブルも同じ schema.prisma に乗せたい。
  3. Session を DB 側に持てる。JWT セッションだと invalidate が難しい (logout や user merge で session 即時失効が要件)。
  4. Server Component から auth() だけで session を取れる

候補は Clerk、Supabase Auth、自作 (lucia-auth)、Auth.js v5。

  • Clerk は magic link を 1 行で足せて UI まで揃うが、user データを Clerk に置くため Prisma スキーマと外部キーで結べない。F-AUTH-04 の「同じメアドのアカウントは統合する」を実装するときに Clerk の API を経由する手数が増える。SaaS 課金も発生する。
  • Supabase Auth は Postgres 上に乗るが、Supabase の RLS (Row Level Security) を使う前提で設計が引っ張られる。Prisma で書いた application logic と RLS の責務が二重化する。
  • lucia-auth は薄くて好きだが、自分で adapter を書く時間を削減したい。
  • Auth.js v5 (beta) は magic link が provider 1 つの設定で動き、Prisma Adapter が公式に存在し、session: { strategy: "database" } で DB セッションになり、Server Component から auth() を呼ぶだけで session が取れる。

選んだのは Auth.js v5 + Resend Email Provider + Prisma Adapter + DB セッション

落とし穴:

  • v5 はまだ beta (next-auth: 5.0.0-beta.31)。breaking change を覚悟して採用する必要がある。
  • 結合テストで next-auth の ESM bundle が next/server を拡張子なしで import するため、Node の resolver が ERR_MODULE_NOT_FOUND を出す。vitest.config.tsserver.deps.inline: ["next-auth", "@auth/core"] で回避する。
  • 本番環境では cookie 名が __Secure-authjs.session-token (https のとき自動で __Secure- prefix が付く)、dev では authjs.session-token という違いがある。E2E や手動テスト時に意識する。

DB / ORM: Postgres 16 + Prisma 6

求めた条件:

  • 並行性の制約を atomic に書きたい (F-INV-08 招待の同時受諾)
  • 型がスキーマから自動生成される
  • migration を git で管理できる
  • 本番でもローカルでも結合テストでも同じ DB を使いたい

候補は SQLite (with Turso / D1)、Postgres + Drizzle、Postgres + Prisma、PlanetScale (MySQL) + Drizzle。

  • SQLite は単一プロセスとデータ規模の前提が違う。SELECT FOR UPDATE のような明示ロックがなく、並行性の検証手段が貧弱。
  • PlanetScale (MySQL) は外部キー制約を伝統的に避ける文化があり、F-INV-08 の atomic claim を「updateMany の WHERE 条件で expressed する」設計と相性が悪い。
  • Drizzle は SQL に近い書き味で速いが、関連 (include) を書くときに毎回 with 句を組み立てる必要がある。1 人開発で MVP を出す段階では Prisma の include の書き心地のほうが先に進める。

選んだのは Postgres 16 + Prisma 6。本番は Neon、ローカルは Docker Compose、結合テストは Testcontainers の Postgres 16 イメージ。3 環境で 同じ DB エンジン が動くので「テストでは通るが本番で落ちる」が発生しない。

落とし穴:

  • Prisma 6 で postinstall フックが消えたpnpm install だけでは usable な client が生成されない。プロジェクト側の package.json"postinstall": "prisma generate" を書いて全環境 (local / CI / Vercel) で回るようにする必要がある。pnpm install --ignore-scripts を使う場面 (一部 CI ジョブ) では明示的に pnpm exec prisma generate を呼ぶ。
  • Prisma 7 への major bump はインフラ移行を伴う。driver adapter (@prisma/adapter-pg)、directUrlprisma.config.ts への移動、new PrismaClient(...) の全 call site の書き換え、これだけ書き換えが要る。dependabot で自動 PR にすると判断負荷が高すぎるので、.github/dependabot.yml で Prisma の major だけ ignore にしている。

バリデーション: zod 4

求めた条件:

  • Server Actions の入力を 1 行で型ごと検証したい
  • 結合テストで境界値検証 (本文 1〜5,000 文字) を書きやすい
  • form state と Server Action の入力検証が二重化しない

候補は zod、valibot、yup、ajv。

  • valibot は tree-shake が効いてバンドルが小さい。新規 SPA で起動速度を厳密に詰めるなら強い。kokan-nikki は SSR 中心で初回 JS を切り詰める優先度が低いので、利点が活きない。
  • yup / ajv は型推論の表現力で zod に劣る。

選んだのは zod 4schema.safeParse(formData) で TS の絞り込みが効き、エラー側は result.error.issues で field エラーを列挙できる。@conform-to/zod を挟めば form の field レベルでも同じスキーマが使える。

落とし穴:

  • zod 4 の .string() は v3 から API がいくつか変わっている (.email() の deprecation など)。dependabot の minor bump で警告が増えるので、リリースノートを軽く読んでから merge する。

スタイル: Tailwind CSS v4 + デザインハンドオフ

求めた条件:

  • デザイントークンをコードに落としたい (kokan-nikki は "Dreamy Pixel" というハンドオフがある)
  • prefers-reduced-motion 連動を後付けで切り替えたい (NF-A11Y-01)
  • スコープ汚染を避ける (Server Component と Client Component が混在するので、grobals.css 1 枚で足を引っ張られたくない)

候補は CSS Modules、vanilla-extract、Tailwind CSS、Panda CSS、Stitches (deprecated)。

  • CSS Modules は素直だが、デザイントークンを type-safe に共有する仕組みは別途用意する必要がある。
  • vanilla-extract は型安全で最高だが、build 設定の追加コストと、エージェント (Claude Code) が CSS-in-TS の構文に詳しくない場面がある。
  • Panda CSS は方向性は良いが、エコシステムが Tailwind に比べて薄い。

選んだのは Tailwind CSS v4 + @tailwindcss/postcss + prettier-plugin-tailwindcss。Tailwind v4 は config を CSS の @theme で書くスタイルになり、デザイントークンを CSS variables で持てる。reduce-motion クラス連動は data- 属性 + arbitrary variant で書ける。prettier-plugin-tailwindcssclassName の utility 順序を自動整列するので、エージェントとの差分レビューが楽になる。

落とし穴:

メール: Resend

求めた条件:

  • magic link 送信が確実 (deliverability)
  • API が薄い (emails.send 1 関数で済む)
  • DKIM / SPF / DMARC が運用できる
  • テスト中に絶対に本番 API を叩かない仕組みが組める

候補は SendGrid、AWS SES、Resend、Postmark。

  • SendGrid は古典で安定だが、API の冗長さと UI の重さがある。
  • AWS SES は安いが、bounce / complaint の自前管理が要求される (SNS topic を購読して suppression list を作る) ので、1 人開発の運用負荷が増える。
  • Postmark は transactional に強いが、価格モデルが Resend より高い。

選んだのは Resend。理由は resend.emails.send({ from, to, subject, react }) 1 行で React コンポーネントから email が送れる体験が速い。SPF / DKIM の DNS 設定は Resend の管理画面が誘導する。kokannikki.app の DMARC は Porkbun に DNS を置きつつ、p=quarantine; pct=25 まで段階的に締める計画を残してある。

落とし穴:

  • テスト中の事故防止tests/ 全体で RESEND_API_KEY を空にして、lib/mailer.ts の dev fallback (tmp/dev-mailbox に書き出す) に倒す。E2E では MSW を Node 側で立てて https://api.resend.com/emails を 200 で握り、呼び出し履歴を assert に使う。「実 API を叩く CI を作らない」がアカウント停止と料金事故の両方を避ける。

ファイルアップロード: @vercel/blob

ユーザーが画像を貼る要件 (将来) のためだけに先回りして導入。S3 + presigned URL 自前実装の代わり。Vercel に乗っているなら、Edge Function からアップロード token を発行する API を 1 ファイルで書ける。

別 hosting に移す可能性が高いプロジェクトでは選ばないが、kokan-nikki は Vercel で完結させる前提なので採用。

テスト: Vitest 4 + Testcontainers + Playwright + axe-core

ここは別記事 kokan-nikki の CI/CD とテスト運用を解説する で詳しく書いた。要点だけ:

  • Vitest 4 で unit と integration の projects を分離。ESM ネイティブで Next.js との相性が良い。Jest を選ばないのは ESM の取り回し。
  • @testcontainers/postgresql で本番と同じ Postgres 16 を結合テストごとに起動。SQLite で代替しない。
  • Playwright は Chromium 1 ブラウザに絞る (MVP)。webServer ではなく global-setup.ts で Postgres + migrate + build + next start を直列に立ち上げる (環境変数 race を避ける割り切り)。
  • @axe-core/playwright で E2E のシナリオ末尾に axe を流して a11y を見る。serious 以上を fail に。
  • Playwright MCP は CI に載せない。人または Claude が対話的に dev サーバを操作する別レイヤとして制度化する。

候補から落としたもの: Cypress (Playwright のほうが multi-tab / multi-context が素直)、Jest (ESM の対応が後手)、Storybook (コンポーネント数が少なく、E2E と axe のほうが情報量が多い)。

静的解析: ESLint 9 + Prettier 3 + TypeScript 6

ESLint 9 は flat config (eslint.config.mjs) に揃え、eslint-config-next を base にしてプロジェクト固有のルールを足す。eslint-config-prettier を最後に並べて formatting 関連のルールを潰す。

Prettier 3 と prettier-plugin-tailwindcss で utility 順序まで含めた整形が走る。CI の lint job で pnpm lint (eslint) と pnpm typecheck (tsc --noEmit) を直列に流し、red のときは feature branch に追加 commit を打つ。

候補から落としたもの: Biome (速いが eslint-config-next の責務との重複が大きい)、dprint (採用してもいいがエコシステムの薄さ)。

デプロイ: Vercel + Neon (本番) / Docker Compose (ローカル)

求めた条件:

  • preview 環境が PR ごとに自動で立つ
  • cron が宣言的に書ける
  • DB が低コストでスケールする
  • ローカルが Docker Compose 1 行で起動する

選んだのは Vercel (アプリ + cron + preview) + Neon (本番 Postgres)。

  • Vercel Preview は PR ごとに別 URL で立ち、エージェントが MCP で実機検証するときに https://kokan-nikki-git-<branch>-...vercel.app を踏んで確認できる。
  • Neon は Postgres branching が独立した DB として preview と本番を分けやすい (今は MVP なので 1 DB だが、将来 preview ごとに branch を切る運用に伸ばせる)。
  • ローカルは docker/docker-compose.yml 1 ファイルで Postgres を立てる。pnpm db:up 1 行。

候補から落としたもの: Fly.io (region が世界中に散る利点は kokan-nikki に過剰)、Railway (preview 環境の自動化が Vercel に劣る)、Render (Vercel と決定的な差はない)。

Cron: Vercel Crons

vercel.json"crons" 配列に { "path": "/api/cron/nudge", "schedule": "0 0 * * *" } を書くだけ。エンドポイント側は Authorization: Bearer ${CRON_SECRET} を要求するように作って、未認証なら 401 を返す (NF-SEC-02)。

候補は Upstash QStash、GitHub Actions の schedule:、ホスト側 cron。Upstash QStash は強力だが kokan-nikki の cron は 1 本だけなので、Vercel に寄せる方が安い。GitHub Actions の schedule は遅延が大きい (5〜15 分) ので magic link や日次リマインダー用途には向かない。

エージェント協業: AGENTS.md / CLAUDE.md

これは「ツール」というよりは規約だが、ツールスタックの一部として扱う価値がある。

AGENTS.md (= CLAUDE.md で取り込まれる) に「これは知っている Next.js ではない」「実装前に node_modules/next/dist/docs/ を読む」「Prisma 6 の postinstall フック消失」などの prior knowledge を集約する。エージェント (Claude Code) は session ごとに training data の古い知識を持ち込むので、project-local な事実を 1 ファイルに集めておくと、毎セッション説明する手間が消える。

docs/testing.md を別ファイルに切ったのも同じ思想で、テスト戦略を「暗黙知」にしておくとセッションごとに迷子になる。

採用しなかった主な選択肢

  • tRPC: Server Actions で十分。RPC レイヤを別途立てる利点が出るのは複数 client (mobile + web) が同じ API を共有するときで、kokan-nikki は web 1 本。
  • Storybook: コンポーネント数が少なく、E2E + axe + Playwright MCP の組み合わせのほうが情報量が多い。
  • Sentry: MVP では Vercel の log と Auth.js v5 の error.digest だけで足りる。production verification を MCP で踏むので、observability の必要量がまだ低い。1.0 以降で再評価する。
  • i18n (next-intl): F-i18n は要件外 (日本語ユーザー対象)。
  • PWA / Service Worker: 要件にオフライン対応がなく、複雑度に対する利得が小さい。

このスタックが向くケース・向かないケース

向くケース:

  • 1 〜 数人 の開発チーム
  • Postgres で完結する relational data 中心のアプリ
  • Vercel に乗せて preview / cron まで使い倒す前提
  • エージェント (Claude Code) との並走 を前提にして、ルールを project-local に集約できる
  • Magic link 認証 が許される (B2B 法人ログインのような複雑な要件がない)

向かないケース:

  • 多テナント B2B (Clerk + Stripe + RBAC のほうが速い)
  • 超低レイテンシ要件 (Edge runtime での Prisma は制約がある、Drizzle + neon-http のほうが向く)
  • mobile / desktop の native app が同居する (Expo + tRPC のほうが共有しやすい)
  • Vercel から離れる可能性 が高いプロジェクト (Vercel Crons / Vercel Blob / Vercel Preview を使うほど移行コストが上がる)

ツール選定で意識した 3 つの視点

スタックを決める作業を振り返って、選定時に意識していた共通の軸が 3 つあった。

  1. 判断負荷が低い側に倒す。1 人開発なので、reviewer = 自分。「迷ったときに正解が出やすい」ツールを選ぶと、無駄な再考が減る。Tailwind の utility 順序自動整列、Prisma の include の書きやすさ、Next.js の Server Actions、これらは判断を消すので速い。
  2. 本番と同じものをローカルで動かす。Postgres、Vercel Preview、Resend MSW、これらは「本番でだけ落ちる」を消す。1 人で書いている以上、本番でだけ起きる事象に気づける目が少ないので、環境差を生む選択は最初に潰す。
  3. エージェントと共有できる前提を文書化する。AGENTS.md、docs/testing.mddocs/blog-pipeline.md、これらは「Claude Code とペアで作るときの暗黙知」を明示知に格上げする。1 人 + エージェントの開発体験は、ルールを文章化した量に比例して安定する。

これらは抽象的な原則ではなく、各層の選定で具体的に効いた。次の Web アプリを作るとき、まずこの 3 つの視点で要件を眺めると、ツール選定の半分くらいは自動的に決まる。

参考リンク