App Router時代の推しディレクトリ構成について語る
medicalforce New Year's Blog 2025の18日目の記事です。ニューイヤー感もなくなってきましたが...笑
この記事では自分が最近のフロントエンド開発でベストだと考えていてかつ実際の開発にも取り入れたことのあるディレクトリ構成について語ろうと思います。
はじめに
弊社ではWEBアプリケーションのフロントエンドをNext.js (App Router) で構築しています。App Routerによりルーティングの表現力は格段に向上し、ディレクトリ構成の正解のようなものはなくなった印象です。今後のより良い設計のためにさまざまなユースケースの事例が欲しいところですがまだ発信は少ない印象で、その一例を示せればと思っています。
構成
monorepo構成を前提とし、共通で利用するLint設定やutil系、atomicなUIコンポーネント群をpackages配下に配置し、アプリケーション側から呼び出します。apiはRESTful apiを想定しています。
# app repo
src/
│
├─ app/
│ ├─ (public)/ # 認証前のルート群(Route Group)
│ └─ (protected)/ # 認証後のルート群
│ ├─ layout.tsx
│ │
│ ├─ @sp/ # SP view(Prallel Route)
│ └─ @pc/ # PC view
│ │
│ └─ path/ # パスの配下で利用されるものを集約
│ ├─ _components/ # コンポーネント群(Private Folder)
│ │ ├─ component.tsx
│ │ └─ component.test.tsx
│ │
│ ├─ _stores/ # global stateを出し入れするhooks
│ ├─ _hooks/ # カスタムhooks
│ ├─ _constants/ # 定数群
│ └─ page.tsx
│
└─ usecase/ # apiリクエストを司るhooks
└─ domain/ # apiが管理するドメインごと
├─ type.ts # フロントエンドで扱うモデル
└─ index.ts # apiリクエストとモデルのキャッシュを作る
# package repo
eslint-config/
lib/
ui/
...
ルートの集約(Route Group)
たとえば認証が必要となるアプリケーションであればこのルート群には認証をかけたいがこのルート群にはかけたくないという要件が発生すると思いますが、それを意識してconfigを書いたりヘルパ関数でwrapしたりしていると漏れるしめんどくさいと思います。かといってその制御のためにパスに protected
などを表出させるのもなんだかイケてない感があります。そういう場合はRoute Groupを使って共通のlayoutファイルを作り認証ミドルウェアを挟めばシンプルに書けます。
const Layout = ({ children }) => <AuthGuard>{chidren}</AuthGuard>
同じルートで異なるコンテンツを配信(Parallel Route)
これは想定されてなさそうな使い方ですが、PC viewとSP viewで表示する内容を分けたい場合はParallel Routeを使います。
const Layout = ({ pc, sp }) => isPC ? pc : sp
レスポンシブ対応はどうしたと突っ込まれそうですが、toCサービスなどでプロダクト価値に直結しない限りはレスポンシブは諦めたほうがいいと思っています。中途半端なレスポンシブ対応はむしろUXを損ねますし、きちんとやろうとするとかなりの保守コストがかかり機能数が多いほど指数関数的にコードの複雑性が上がります。
ルート配下で使うものはルートの中にまとめる(Private Folder)
世の中的には機能単位でfeaturesに集約する構成が多いと思いますが、UIにおける機能をどのような大きさで切り取るか、どのように命名するかのルールを言語化するのは難しく、ことチーム開発においては中途半端な運用に落ち着いてしまう可能性が高いと考えています。きちんと言語化できない構成は無駄な認知コストを増やすだけです。
一方WEBフロントエンドではUIが一番の関心ごとであり、それはルートごとに配信されるのでその単位で集約するのが綺麗だと考えています。
ルート内完結を徹底させるために、ルーティングの起点となるpage componentからはその祖先の配下にあるprivate folderしか参照できないようなlintルールを作るとより良いです。
├─ AAAAA/
│ ├─ BBBBB/
│ │ ├─ _components/ # import NG
│ │ └─ page.tsx
│ └─ CCCCC/
│ ├─ _components/ # import ok
│ └─ DDDDD/
│ ├─ _components/ # import ok
│ └─ page.tsx # ここで何かをimportするとき
そうするとルートを超えての共通化ができないじゃないかと突っ込まれそうですが、その共通化は捨てたほうが良いと考えています。componentを例にとるとatomicデザインでいうorganismsくらいのものを共通化したいという話はあるあるかと思いますが、これは実装時にたまたま同じ見た目のものが複数箇所で使われているだけであると考えます。実際、機能拡張とともに次々と"共通化同盟"を離脱していき、それでも同盟を維持しようと魔改造されて膨大なpropsを擁するcomponentに化けることは多々あるかと思います。たまたま同じ見た目のものは別物なので共通化せずに各ルートにおくのが良いと考えるわけです。
App RouterはColocationを志向していますが、それに振り切った構成になってます。
usecaseディレクトリ
下記記事を参考にして作っています。App Routerの話とズレるのとそのまま参考にさせていただいているので詳細は本家記事をご覧ください。記事ではRepositoryとUsecaseが分かれていますがまとめています。キャッシュコントロールとapiリクエストの二つの責務を負っているので記事のように分けたほうがいいかもしれませんが、まとめる運用で特に課題感はないので長らく今の構成になっています。
以上です。皆さんの推し構成もぜひ教えてください。
Discussion