はじめに
🤔「AI に実装を任せたら、似たようなファイルがあちこちにできていた…」
😵「App Router の app/ に、どこまで実装を書いていいの?」
🫠「状態管理ライブラリ、とりあえず入れておくべき…?」
以前、こんな記事を書きました👇
🔗 モダン開発のディレクトリ構成設計!中大規模プロジェクトでも破綻しない実践的なファイル管理術
いろいろなプロジェクトで試してきた、今いちばんしっくりきているディレクトリ構成をまとめたものです📁
この構成は、今も複数のプロジェクトで使っています。
そのうちの 1 つが、個人開発している食の口コミサービス「あじぴた」です。
4,300 コミットを超える実サービスで使ってみて、前回の記事の答え合わせができたので、まとめます✍️
答え合わせで分かったことは、大きく 3 つです。
- 前回の記事の原則は、ほぼそのまま通用した
- App Router 向けに、足したものがいくつかある
- 一番の発見は、ルールが明確な構成は AI にもそのまま効くことだった🤖
結論:迷わない構成は、AI に任せる時代ほど効く
先に結論です👇
「どこに何を置くか」が名前と階層だけで決まる構成は、そのまま AI への指示になる。
あじぴたのコードの多くは、Claude Code が書いています。
そこで効いたのが、判断の余地が無いディレクトリ構成でした💡
Button を作るなら src/components/elements/Button/Button.tsx。
型は同じディレクトリに button.type.ts。
ルールが決まっているので、AI が置き場所で迷いません。実装の指示も短く済みます。
逆に言えば、あいまいさを残したルールは、AI と相性が悪いです😇
前回の記事から、そのまま通用したもの
まずは、前回の記事の内容で変えずに使っているものです✅
コンポーネントの 5 分類
| 置き場所 | 条件 | あじぴたでの数 |
|---|---|---|
elements/ |
最小単位の UI パーツ(Button・Input・Tag など) | 20 |
layouts/ |
ヘッダー・フッター・ナビゲーション | 9 |
common/ |
複数の機能で使う UI(業務ロジックなし) | 25 |
features/[機能名]/ |
機能ごとの UI(業務ロジックあり) | 4 機能 |
pages/[ページ名]/ |
特定のページ専用 | 28 |
判断の基準は、**「業務ロジックを持つか」「どこから使われるか」**の 2 つだけです☝️
この 2 つで迷わず決まるので、数が増えても置き場所に困りませんでした。
Atomic Design は使わない
前回も書いたとおり、Atomic Design は採っていません。
atom・molecule・organism の境界で毎回迷うからです🤷
この「境界で迷う」は、人間にとっては判断の手間でした。
でも AI に任せると、もっと直接的な問題になります。
迷うたびに、AI はそのときどきで違う判断をするからです🌀
関連ファイルは、同じディレクトリに置く
Button/
├── Button.tsx
└── button.type.ts
型やテストを別のディレクトリに集めず、対象と同じ場所に置きます。
コンポーネントを消すときは、ディレクトリごと消せば終わりです🧹
これが、実サービスで一番効いたルールでした。
規模が大きくなるほど効きます。
index.ts(バレルファイル)は作らない
import { Button } from './Button' のように書けると楽ですが、
実体がどのファイルにあるのか分からなくなるので作っていません🙅
あじぴたの src/components 配下には、index.ts は 1 つもありません。
AI がコードを読むときも、import の行を見れば実体の場所がそのまま分かります👀
App Router 向けに足したもの
ここからが、前回の記事には無かった部分です🆕
前回の記事は特定のフレームワークに寄せない内容だったので、App Router 向けにいくつか足しました。
app/ はルーティングだけ、実装は src/ に置く
app/ ← ルーティングの定義だけ
├── (auth)/ ログイン関連
├── (onboarding)/ 初回の診断
├── (main)/ メインの画面(ログインが必要)
└── api/ API ルート
src/ ← 実装はすべてこちら
├── components/
├── server/ サーバー専用の処理
├── hooks/ libs/ stores/ utils/ types/ constants/ styles/
app/[ページ]/page.tsx に書くのは、データの取得と、src/components/pages/ のコンポーネントへの受け渡しだけです。
画面の中身(UI)は、すべて src 側に置きます。
// app/(main)/home/page.tsx:データを取って、ページのコンポーネントに渡すだけ(簡略化)
import { HomePage } from '@/components/pages/home/HomePage/HomePage'
import { requireUser } from '@/libs/supabase/auth'
import { getHomeActivityFeed, getHomeRecommendedDishes } from '@/server/home/home.service'
export default async function Page() {
const user = await requireUser()
const [feed, recommended] = await Promise.all([
getHomeActivityFeed(user.id),
getHomeRecommendedDishes(user.id),
])
return <HomePage feed={feed} recommended={recommended} />
}
理由は、フレームワークのルールと、自分の設計のルールを混ぜないためです💡
App Router には page.tsx・layout.tsx・loading.tsx といった、ファイル名に意味があるルールがあります。
ここに実装を書き始めると、「フレームワークのルール」と「自分の設計のルール」が同じ場所でぶつかります💥
src/server/ にサーバー専用の処理を置く
DB へのアクセスなど、サーバーでしか動かしてはいけない処理は src/server/ にまとめています。
今は 25 の機能に分かれています🗂️
// src/server/reviews/reviews.service.ts
import 'server-only' // ブラウザ側に紛れ込んだら、ビルドが失敗する
server-only を付けておくと、間違って Client Component から読み込んだときにビルドの時点で気づけます🛑
置き場所だけでなく、間違えたら止まる仕組みもセットにしています。
ここで DB へのアクセスをどう守っているかは、前回の記事で詳しく書きました🔑
🔗 「全部見せるためのRLS」は書かない。Supabaseで管理画面だけRLSをバイパスした理由
全コンポーネントをディレクトリ形式にする
✅ src/components/elements/Button/Button.tsx
❌ src/components/elements/Button.tsx
1 ファイルしか無いコンポーネントでも、例外なくこの形にしています。
「今は 1 ファイルだから直接置き、増えたらディレクトリにする」を許すと、
移動するたびに import のパスが変わります😵
最初からディレクトリにしておけば、後から型やテストが増えても構造を変えずに済みます。
1 ファイルでもディレクトリを切るので、手数は増えます。
でも実装するのは AI なので、その手間は問題になりませんでした🤖
人間が手で書いていた頃なら気になった冗長さも、AI に任せる前提なら、迷わないことのほうがずっと価値があります。
親専用の子コンポーネントは、親の直下に置く
HomeStatus/
├── HomeStatus.tsx ← 骨組みだけ
├── HomeStatusGreeting/ ← HomeStatus 専用の子
├── HomeStatusBadgeProgress/ ← HomeStatus 専用の子
└── HomeStatusRankProgress/ ← HomeStatus 専用の子
components/ のような中間のフォルダは作りません。
名前は**「親の名前 + 役割」**にして、どこに属するかが名前だけで分かるようにしています🏷️
前回から一番変わったところ:stores/ がほぼ空になった
前回の記事では、stores/ に Zustand などの状態管理ライブラリを置く想定でした。
あじぴたの src/stores/ にあるのは、2 ファイルだけです😲
しかも状態管理ライブラリは1 つも入れていません。React 標準の useSyncExternalStore で作っています。
なぜ要らなくなったのか
App Router では、サーバーから取ってきたデータの置き場所が変わりました🔄
これまで(SPA): API を呼ぶ → 状態管理ライブラリに入れる → 画面に出す
App Router: Server Component がデータを取る → そのまま画面に出す
状態管理ライブラリの仕事の多くは、「サーバーから取ってきたデータを、ブラウザ側で持っておく」ことでした。
それを Server Component が引き受けるなら、残るのは本当にブラウザ側だけの状態です。
あじぴたで残ったのは、この 2 つだけでした。
- Cookie 同意バナーの開閉と、同意の状態
- 通報済みの口コミの一覧(表示の出し分けに使う)
この規模なら、ライブラリは要りません👍
自作するときにハマりやすい点
useSyncExternalStore を素直に書くと、画面の再描画が止まらなくなることがあります⚠️
状態を返す関数(getSnapshot)は、中身が変わっていないなら、毎回同じオブジェクトを返す必要があるからです。
// Before: 呼ばれるたびに新しいオブジェクトを返すので、再描画が止まらない
const getSnapshot = () => ({ consent, hydrated, dialogOpen })
// After: 状態を変数に持っておき、更新したときだけ作り直す
let snapshot = INITIAL_SNAPSHOT
const setSnapshot = (next) => {
snapshot = { ...snapshot, ...next }
for (const listener of listeners) listener()
}
const getSnapshot = () => snapshot
もう 1 つは、サーバーとブラウザで最初の表示が食い違う問題です😵
サーバーは Cookie の同意状態を知らないので、最初は「まだ読み込んでいない」状態を返し、
画面が表示された後に 1 回だけブラウザ側で状態を読み込むようにしています。
いつ入れるかの線は、引いていない
「こうなったらライブラリを入れる」という明確な条件は、決めていません。
ブラウザ側で持つ状態が増えてきたら入れる、それだけです。
効いてくるのは状態の「数」より、状態どうしが絡み合っているかどうかだと思っています。
独立した状態が 5 つあるより、お互いに依存する状態が 3 つあるほうが厄介です🧶
ただ、基本の姿勢として依存はできるだけ減らしたいと考えています。
ライブラリを 1 つ入れると、使い方を覚える手間・バージョンの追従・「なぜこれを使っているのか」の説明がついてくるからです。
数年前なら反射的に入れていたはずのものを、今は一度立ち止まって考えられる。
Server Component が主流になって、状態管理ライブラリの出番そのものが減っていると感じています。
まとめ
- 前回の記事の原則(5 分類・Atomic Design を使わない・関連ファイルを同じ場所に・
index.tsを作らない)は、実サービスでもそのまま通用した✅ - App Router では、
app/はルーティングだけ、実装はsrc/に分ける🗂️ - サーバー専用の処理は
src/server/に置き、server-onlyで間違えたらビルドで止まるようにする🛑 - Server Component が主流になると、状態管理ライブラリの出番は大きく減る。あじぴたでは 2 ファイルだけだった
- ルールが明確な構成は、AI に任せる時代ほど効く🤖
「どこに置くか」で迷わない構成は、人間のためだけでなく、AI に実装を任せるための土台にもなります。
ディレクトリ構成に悩んでいる方の参考になれば嬉しいです💡
シリーズの他の記事も、よろしければ📚
このシリーズでは、個人開発サービス「あじぴた」の設計をテーマごとに書いています。
サービスの全体像や、ほかの記事の一覧はハブ記事にまとめています👇
🔗 「低評価を公開しない」口コミサービスを個人開発した話 — 4,300コミット・6リポジトリの全体像
これまでに公開した記事です。
🔗 Google Places APIで月$1,440の請求が来る前に — 個人開発で従量課金を「呼ばない」8層の防壁
🔗 Next.js 16 で Web Vitals を測って直した実録!loading.tsx で LCP が 2 倍になった罠と関数リージョン
🔗 「全部見せるためのRLS」は書かない。Supabaseで管理画面だけRLSをバイパスした理由
次の記事では、「低評価を公開しない」という仕様が、データベースの選定まで決めた話を書きました🍽️
🔗 漏洩してもエラーは出ない。非公開データをアプリではなくDBで守る、SupabaseのRLSを選んだ理由
設計の中身を通して読みたい方には、解剖ドキュメントも公開しています🔬
🔗 あじぴたの内側(ajipita-inside)
参考になれば幸いです🙏