Next.jsのディレクトリ構成で迷わない。モヤモヤを納得感に変えるための判断基準まとめ

私は普段、Next.jsを使ってWebページやWebアプリケーションを制作することが多いです。
制作のスタイルとしては、まず「動くもの」を最優先にするため、一旦 page.tsx に必要なコードをガッーっと全て書いてしまいます。そして機能が動くことを確認してから、適切な場所にコードを移動させていくという流れで進めています。
しかし、いざ整理しようとした時に必ずと言っていいほどぶつかる壁があります。
「あれ、ディレクトリ構成どうしてたっけ?」
「この関数は utils? それとも hooks?」「型定義はどこに置こう?」 迷うたびに毎回過去のプロジェクトを開いて確認するのは、正直かなり面倒です。
これまでの記事ではリファクタリングの「心得」や「考え方」をまとめてきましたが、今回は**「迷った時の答え」となるような、より実践的で細かいディレクトリ構成の判断基準**についてまとめたいと思います。
関心の分離
まずは基本的な考え方から整理します。 私はコードの責務に応じて、次の表のように5つのディレクトリに分けて考えています。
| 分離対象 | 格納先ディレクトリ | 特徴 |
|---|---|---|
| 型定義 | types/ | interface, type など。実行時には消える型情報。 |
| ビジネスロジック | lib/utils/ | 純粋関数。副作用がなく、計算・変換・バリデーションなどを行う。 |
| API呼び出し | lib/api/ | 非同期関数。ネットワーク通信などの副作用を伴う。 |
| 状態管理ロジック | lib/hooks/ | Reactの useState, useEffect を使うカスタムフック。コンポーネント専用 |
| UI | components/ | 見た目や構造を定義するReactコンポーネント。 |
この分類、頭では理解できているつもりなんです。 でも、いざ作業をしながら手を動かしていると、なぜか迷子になってしまうんですよね。
最近やっと、その原因がわかってきました。 それは、「分ける必要のないもの」と「分けた方が良いもの」の線引きを、その場の「感覚」でやっていたことが原因でした。
「関心の分離的にはディレクトリに分けた方がいいよな……」と思いながら、 「でも、これくらいなら分けない方がパッと見わかりやすいよな……」とも考えてしまう。
この行ったり来たりが、最終的に「わけがわからない状態」を作っていました。
そこで、過剰に分離しすぎないためにも、もう迷わないためのルールを自分の中で決めることにしました。
(もちろん「繰り返し使う物を分ける」という大前提は理解しています。でも、「ひとつはimportして、もうひとつは直書き」っていう不揃いな状態……なんかモヤモヤするじゃないですか。。。)
型定義の分離(types/)
しょっちゅう迷うものの筆頭が、この型定義です。
これも制作の流れとしてはこれまで同様、最初はページコンポーネントなどに書いてしまいます。 ただ、APIのレスポンスの型だけは、最初から types ディレクトリに分けて書くようにしました。
それ以外の型定義は、まずはコンポーネント内に書く。 そして、他の箇所でも利用するようになったタイミングで初めて types ディレクトリに移動する、という運用に落ち着きました。
以前は「型定義は全部 types にあるべき!」と思って集めていた時期もあったのですが、それだと逆に「この型、どこで使ってるんだっけ?」といちいち探す羽目になってしまって……。
なので今は、**「使い場所の近くに置く」**を基本にしています。
| 状況 | 置き場所 | 型名の例 |
|---|---|---|
| そのコンポーネントでしか使わない | コンポーネントと同じファイル | ButtonProps |
| 複数箇所で使うデータ構造 | types/ | Menu |
| APIレスポンスの型 | types/ | MenuListResponse |
【コンポーネント内に置く例】 たとえば、ボタンコンポーネント(Button.tsx)でしか使わない ButtonProps は、Button.tsx ファイルの中に直接定義します。わざわざ外に出すことはしません。
// components/ui/Button.tsx
type ButtonProps = {
label: string; // ボタンに表示するテキスト
onClick: () => void; // クリック時の処理
};
export const Button = ({ label, onClick }: ButtonProps) => {
return <button onClick={onClick}>{label}</button>;
};
【types/ に置く例】 一方で、Menu(メニュー情報)のように、一覧画面・詳細画面・フォームなどアプリ内のあちこちで使うデータ構造は、types/menu.ts などに定義して、みんなで参照できるようにしています。
// types/menu.ts
// メニュー1件のデータ構造
export type Menu = {
id: string;
name: string;
price: number;
};
// API: メニュー一覧取得のレスポンス
export type MenuListResponse = {
menus: Menu[];
total: number;
};
ロジックの分離(lib/)
ロジックの分離は、「やるぞ!」と決めている時はサクサク進むのですが、何も考えずに実装を進めてしまうと、後から切り出すだけでも一苦労です。
特にユーティリティ関数に関しては、一気に作っている段階でもある程度関数に切り分けておかないと、後からロジックだけを引き剥がすのが難しくなりがちでした。
APIに関しては、型定義と同じく**「始めから分けて作業する」**ことにしています。
以前は機能ごとのディレクトリ内に actions.ts を作成して対応していました。 それでも困ることはなかったのですが、「型は分けているのに、ロジックは分けない」というあべこべな状態が気になってしまったため、現在は統一しています。 (ディレクトリを分けていなかったのは、そこまで大規模な開発が少ないというのも理由の1つです。)
最後にカスタムフックですが、この切り分けの判断基準はシンプルです。
「useState や useEffect などで状態管理をしていたら、hookに分ける」
これだけです。 私自身、そこまで頻繁にカスタムフックを作るわけではありませんが、モーダルの表示管理やハンバーガーメニューの実装などで、少し書く程度です。
| 項目 | lib/utils/ | lib/api/ | lib/hooks/ |
|---|---|---|---|
| 役割 | 計算・変換 | API通信 | 状態の保持・更新 |
| 状態 | 持たない | 持たない | 持つ(useState) |
| 副作用 | なし | あり(通信) | あり(状態更新) |
| 使用場所 | どこでも | どこでも | Reactコンポーネント内のみ |
| 命名規則 | 自由 | 自由 | useで始まる |
| 例 | aggregateMaterials | fetchMenus | useMenuAdd |
UIの分離(components/)
UIの分離についても、制作の途中で迷うことがよくあります。
https://hufoo.jp/blog/create/2ed96e4b-169b-807f-a33e-f66c44da0b8c
この記事でも書いたように私はこれまで、Atomic Designの考え方をベースにコンポーネントを分けてきました。この手法はロジカルに分類できるので、最初は快適に進められていたのですが、制作を重ねるうちにモヤモヤするポイントがあることに気づきました。
Atomic Designの考え方をベースにコンポーネントをわけています。
それは、page.tsxはSSR(サーバーサイドレンダリング)で枠だけ作り、中身はCSR(クライアントサイドレンダリング)で書かれているといった状況の時、コンポーネントをどこに置くべきかという点です。
今までの自分なら、何も考えずに components/feature/ディレクトリ名 に分けていました。しかしある時、「他のページで使い回すわけでもないのに、わざわざ遠くの共通ディレクトリに置く必要があるのか?」と疑問を持つようになりました。
このモヤモヤを解消するために、次のような自分ルールを固めました。
「ページ専用」と「features」の違い
- ディレクトリ(ページ専用)に作る場合
- page.tsx がSSRのページ。
- 中身がそのページだけで利用するCSRのコンポーネントである場合。
- featuresに作る場合
- 同じようなものを、他のところでも使う場合に分ける。
- 最初はディレクトリに分けないで、コンポーネントが増えてきてから分類を検討する。(最終的にそこまで大きくならないことも多いため)
手順を一貫させる
難しいことを考えずに分けるためには、次の手順を一貫させることが大切です。
page内に作る → 同じものが必要になる → featureに移す → 増えてきた → ディレクトリに分ける
この工程で制作を進めるようになってから、UIのディレクトリ構成に関するモヤモヤがなくなり、納得感を持って取り組めるようになりました。
どの要素までコンポーネントに含めるか(ラップ要素の扱い)
ディレクトリ構成とは別の話になりますが、「親の div などのラップ要素をコンポーネントに含めるかどうか」もよく悩みます。
たとえば、page.tsx 内に次のような記述があったとします。この時、外側の div ごとコンポーネント化するのか、中身の Link 部分だけを分けるのか、という疑問です。
<div className="bottom-5 fixed inset-x-5">
<Link
href={href}
className={`relative text-center mt-5 text-white flex rounded-md p-4 justify-center items-center gap-2 border border-teal-700 hover:bg-white hover:text-teal-700 transition-colors w-full cursor-pointer ${isPending ? "bg-teal-500" : "bg-teal-600"}`}
>
{children}
<ChevronRightIcon className="h-4 absolute right-4 top-1/2 -translate-y-1/2" />
</Link>
</div>
これもルール化して解決しました。
- ラップ要素を含めない
- components/ui/ に置くような汎用パーツ。
- 今回の例なら、中身の Link 要素だけを共通パーツにします。外(親)のスタイルに依存できるようにしておくことで、他の場所でも使い回しやすくなります。
- ラップ要素を含める
- features やページ専用ディレクトリに置くような、特定の画面専用で見た目が固定されているパーツ。
- 配置も含めて丸ごとコンポーネント化した方が、呼び出し側がスッキリします。
このように分けることで、汎用性と、変更があった時の修正のしやすさを両立させています。
index.tsでまとめる
これに関しては、以前別の記事で書いた考え方から大きくは変わっていないので、基本はそちらに準拠しています。
https://hufoo.jp/blog/create/2d896e4b-169b-8078-a808-fbf8d15bc159#heading-12
ただ、運用を続ける中で一つ決めたルールがあります。それは、**「Default export は page.tsx や layout.tsx 以外では使わない」**ということです。コンポーネントやロジックはすべて Named export(名前付きエクスポート)で統一するようにしました。
以前の記事では「型のまとめ方」を紹介しましたが、ここではUIコンポーネントをまとめる具体的な方法を例として記載します。
たとえば、atom ディレクトリ内に input.tsx と label.tsx があるとします。
components/ui/atom/input.tsx
import { InputHTMLAttributes } from "react";
type Props = InputHTMLAttributes<HTMLInputElement>;
export function Input({ name, id, defaultValue, disabled }: Props) {
return (
<input
name={name}
id={id}
defaultValue={defaultValue}
disabled={disabled}
className="w-full rounded-md bg-gray-100 p-3 mt-2 focus:shadow-md focus-visible:outline-none focus:border focus:border-gray-400 focus:bg-white"
/>
);
}
components/ui/atom/label.tsx
import { ReactNode } from "react";
type Props = {
children: ReactNode;
htmlFor: string;
className?: string;
};
export function Label({ htmlFor, children, className }: Props) {
return (
<label htmlFor={htmlFor} className={`block font-bold ${className ?? ""}`}>
{children}
</label>
);
}
components/ui/atom/index.ts
これらのコンポーネントを、同じディレクトリ内の index.ts でまとめてエクスポートします。
export { Input } from "./input";
export { Label } from "./label";
page.tsx
各ディレクトリでバラバラに定義されたコンポーネントも、index.ts を経由させることで、使う側での記述がシンプルになります。
import { Input, Label } from "@/components/ui/atom";
export default function Page() {
return (
<form>
<Label htmlFor="email">メールアドレス</Label>
<Input id="email" name="email" />
</form>
);
}
index.ts を活用するメリット
- インポートの記述がスッキリする 使う側で import { Input, Label } from "@/components/ui/atom"; と一行で書けるようになります。
- ディレクトリを「一つのモジュール」として扱える ディレクトリ内部のファイル構成を整理・変更しても、外部(使う側)のインポートパスに影響が出ないため、リファクタリングがしやすくなります。
おわりに
設計や制作ルールを事前に決めたはずなのに、コーディング中に「あれ、これどうするんだっけ?」と迷って手が止まってしまう……。そのたびに感じる**モヤモヤとした感情をなくしたい!**と思い、今回のルールをまとめました。
開発の規模や技術の進化によって、最適なルールは変わっていくものだと思います。その時々のプロジェクトに合わせてルールを見直し、アップデートし続けながら、快適なコーディングライフを送っていきましょう!
自分なりの明確な基準を固めたことで、以前よりも迷う時間が減り、よりコーディングそのものに集中できるようになりました。
