Next.js App Router時代のフォルダ設計:Colocationで実現する開発効率
Next.js App Routerは、従来のPages Routerと比較してルーティングとデータフェッチングのパラダイムを大きく変化させました。特に大規模アプリケーション開発において、App Routerが推奨する「Colocation(併置)」の概念を適切に理解し、実践することで、開発効率と保守性を大幅に向上させることが可能です。
本記事では、Next.js 14.xおよびReact 18.x/19.x環境において、App RouterのColocationパターンを活用したフォルダ設計の具体的な方法、実装例、そしてよくある課題とその解決策を解説します。
App RouterとColocationの基本
Next.js App Routerでは、appディレクトリ内のフォルダ構造がURLパスと密接に連携します。各フォルダはURLセグメントに対応し、page.tsxまたはroute.tsファイルが存在するフォルダのみが公開ルートとなります。
Colocation(併置) とは、このappディレクトリの特性を最大限に活かし、特定のルートや機能に関連するすべてのファイルをそのルートのすぐ隣に配置する設計原則です。これにより、機能が自己完結型となり、コードの見通しが良くなり、変更の影響範囲を局所化できます。
App Routerの主要な機能とColocationへの関連性は以下の通りです。
-
ファイルシステムルーティング:
app内のフォルダがルーティング構造を定義します。 - Server Components (デフォルト): コンポーネントはデフォルトでサーバーでレンダリングされ、データフェッチングとコンポジションを行います。
-
Client Components (
"use client"): インタラクティブな部分にのみ使用し、ブラウザAPIへのアクセスや状態管理を行います。 -
特殊ファイル:
page.tsx,layout.tsx,loading.tsx,error.tsxなど、特定のUIや動作を定義します。これらを関連するルートの隣に配置することがColocationの基本です。 -
Route Groups (
(folder)): URLパスに影響を与えずにルートセグメントをグループ化し、異なるレイアウトの適用などに利用します。Colocationと組み合わせることで、論理的なグループ化を促進します。 -
Private Folders (
_folder): ルーティングシステムから完全に除外したいファイルを格納し、内部的なコンポーネントやユーティリティを整理します。
Colocationを活用したフォルダ設計の具体例
ここでは、Colocationパターンに基づいた具体的なフォルダ構造とコード例を示します。
フォルダ構造の例
app/
├── (dashboard)/ # ルートグループ (URLに影響しない)
│ ├── layout.tsx # ダッシュボード全体のレイアウト
│ ├── page.tsx # ダッシュボードのメインページ (/dashboard)
│ ├── _components/ # ダッシュボード固有のコンポーネント (ルーティングされない)
│ │ ├── DashboardHeader.tsx
│ │ └── UserGreeting.tsx
│ ├── settings/
│ │ ├── page.tsx # 設定ページ (/dashboard/settings)
│ │ ├── SettingsForm.tsx # 設定ページ固有のフォームコンポーネント
│ │ └── actions.ts # 設定ページ固有のサーバーアクション
│ └── analytics/
│ └── page.tsx # アナリティクスページ (/dashboard/analytics)
├── (marketing)/ # 別のルートグループ
│ ├── layout.tsx # マーケティングサイト全体のレイアウト
│ └── about/
│ └── page.tsx # 会社概要ページ (/about)
├── api/ # APIルートハンドラ
│ ├── users/
│ │ └── route.ts # /api/users のAPIエンドポイント
│ └── posts/
│ └── route.ts # /api/posts のAPIエンドポイント
├── _lib/ # アプリケーション全体で共有されるユーティリティ (ルーティングされない)
│ ├── utils.ts
│ └── db.ts # ダミーのDB操作関数を想定
├── _hooks/ # アプリケーション全体で共有されるカスタムフック (ルーティングされない)
│ └── useAuth.ts
└── layout.tsx # ルートレイアウト (<html>, <body>など)
この構造では、以下の原則に基づいています。
-
機能別グループ化:
(dashboard)や(marketing)のように、関連するルートをルートグループでまとめ、その中にページ固有のコンポーネントやロジックをColocateしています。 -
Private Foldersの活用:
_components,_lib,_hooksのようにアンダースコアを付けたフォルダは、ルーティングされない内部的な共有コンポーネントやユーティリティを格納します。これにより、グローバルなコードとルート固有のコードを明確に分離し、ルートフォルダの散乱を防ぎます。 -
特殊ファイルの配置:
layout.tsx,page.tsxなどの特殊ファイルは、その役割が適用されるスコープのフォルダ直下に配置します。
コード例:Server ComponentとClient Component、Server Actionの連携
app/(dashboard)/settingsルートを例に、Server Component、Client Component、Server ActionをColocationで配置する具体例を示します。
1. app/(dashboard)/settings/page.tsx (Server Component)
このページはサーバーでレンダリングされ、初期データをフェッチします。
// app/(dashboard)/settings/page.tsx
import { dummyGetUserSettings } from '@/app/_lib/db'; // サーバーサイドのデータフェッチングを想定
import SettingsForm from './SettingsForm'; // 同じディレクトリ内のClient Componentをインポート
// ダミーのgetUserSettings関数(_lib/db.tsに相当するものを仮で定義)
// 実際には_lib/db.tsに定義し、そこからインポートする
async function dummyGetUserSettings() {
await new Promise(resolve => setTimeout(resolve, 500));
return { userName: 'John Doe', email: 'john.doe@example.com' };
}
export default async function SettingsPage() {
// Server Component内で直接データをフェッチ
const settings = await dummyGetUserSettings();
return (
<div>
<h1>Settings</h1>
<p>User Name: {settings.userName}</p>
{/* Client ComponentをServer Component内でレンダリング */}
<SettingsForm initialSettings={settings} />
</div>
);
}
ポイント:
-
"use client"ディレクティブがないため、デフォルトでServer Componentです。 -
async/awaitを使用して、サーバーサイドで直接データをフェッチします。これは不要なAPIエンドポイント呼び出しを避け、パフォーマンスを向上させます。 -
SettingsFormはClient Componentですが、Server Componentからインポートし、そのプロパティとして初期データを渡します。
2. app/(dashboard)/settings/SettingsForm.tsx (Client Component)
このフォームはクライアントサイドでインタラクティブな動作を提供します。
// app/(dashboard)/settings/SettingsForm.tsx
"use client"; // Client Componentであることを明示
import { useState } from 'react';
import { updateUserSettings } from './actions'; // 同じディレクトリ内のServer Actionをインポート
interface SettingsFormProps {
initialSettings: {
userName: string;
email: string;
};
}
export default function SettingsForm({ initialSettings }: SettingsFormProps) {
const [userName, setUserName] = useState(initialSettings.userName);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
setMessage('');
try {
// Server ActionはClient Componentから直接呼び出せます
const result = await updateUserSettings(userName);
if (result.success) {
setMessage('Settings updated successfully!');
} else {
setMessage('Failed to update settings: ' + result.error);
}
} catch (error) {
setMessage('Failed to update settings due to an unexpected error.');
console.error(error);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="userName">User Name:</label>
<input
id="userName"
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
disabled={isLoading}
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Saving...' : 'Save Settings'}
</button>
{message && <p>{message}</p>}
</form>
);
}
ポイント:
-
"use client"ディレクティブにより、Client Componentとして動作し、useStateなどのReact Hooksを使用できます。 - 同じディレクトリにColocateされた
actions.tsからServer Actionをインポートし、フォーム送信時に直接呼び出しています。これにより、APIルートハンドラを介さずにサーバーサイドの処理を実行できます。
3. app/(dashboard)/settings/actions.ts (Server Action)
このファイルはサーバーサイドで実行されるアクションを定義します。
// app/(dashboard)/settings/actions.ts
"use server"; // Server Actionであることを明示
import { revalidatePath } from 'next/cache';
// ダミーのsaveUserSettingsToDb関数
// 実際にはDBモジュールなどをインポートして使用
async function dummySaveUserSettingsToDb(newUserName: string) {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log(`User settings updated in DB: ${newUserName}`);
return { success: true };
}
export async function updateUserSettings(newUserName: string) {
try {
await dummySaveUserSettingsToDb(newUserName);
// データ変更後、関連するパスのキャッシュを再検証
revalidatePath('/dashboard/settings');
return { success: true };
} catch (error: any) {
console.error("Error updating settings:", error);
return { success: false, error: error.message || "Unknown error" };
}
}
ポイント:
-
"use server"ディレクティブにより、Server Actionとして動作し、データベースアクセスなどのサーバーサイド処理を実行できます。 -
revalidatePathやrevalidateTagを使用して、データ変更後にNext.jsのデータキャッシュを適切に更新します。
よくあるハマりどころと回避策
ColocationとApp Routerの特性を理解していないと、いくつかの一般的な問題に直面する可能性があります。
エラー1: Client ComponentでのサーバーサイドAPIの不必要な呼び出し
ハマりどころ: Client Component内でfetchを使ってAPIルートハンドラを呼び出したり、Server Component内で同じサーバー上で動作するRoute Handlerをfetchで呼び出したりすること。これは不要なネットワークホップを生み出し、パフォーマンスを低下させます。
回避策:
-
Server Component: データをフェッチする際は、外部APIを直接呼び出すか、Server Actionで定義した関数を直接呼び出します。同じNext.jsアプリケーション内のAPIルートハンドラを
fetchで呼び出すのは、通常不要です。 - Client Component: データを変更する際はServer Actionを使用します。データをフェッチする必要がある場合は、Server Componentでデータをフェッチし、Client Componentにはプロパティとして渡すことを優先します。どうしてもClient Componentでフェッチが必要な場合は、APIルートハンドラを呼び出しますが、その設計自体を再検討します。
エラー2: "use client"の過剰な使用
ハマりどころ: 習慣的にすべてのコンポーネントに"use client"を付けてしまい、Server Componentsのパフォーマンス上の利点を失ってしまうこと。JSバンドルサイズが増大し、クライアントサイドでのレンダリングが増えます。
回避策: デフォルトはServer Componentsであると認識し、ブラウザ固有の機能(状態管理、エフェクト、イベントハンドラ、ブラウザAPIへのアクセスなど)が必要な場合にのみ"use client"を使用します。Client Componentは「葉」のような存在(インタラクティブな末端部分)として保つことが推奨されます。
エラー3: Context Providerの誤った配置
ハマりどころ: Context ProviderをServer Component内で直接使用しようとしたり、アプリケーションツリーのルートに不必要に配置したりすること。Server ComponentはContextをサポートしていません。また、Providerを必要以上に上位に配置すると、Next.jsの最適化の機会を減らす可能性があります。
回避策: Context ProviderはClient Componentで作成し、必要となる最も低いレベルのlayout.tsxファイルに配置します。ルートのlayout.tsxでグローバルなProviderが必要な場合は、"use client"をマークした別のファイル(例: app/providers.tsx)にProviderを分離し、ルートのlayout.tsxでそれをインポートして使用します。
// app/providers.tsx (Client Componentとして定義)
"use client";
import { createContext, useContext, useState, ReactNode } from 'react';
interface ThemeContextType {
theme: string;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('light');
const toggleTheme = () => setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
// app/layout.tsx (Server ComponentからClient ComponentのProviderをインポート)
import { ThemeProvider } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ThemeProvider>
{children}
</ThemeProvider>
</body>
</html>
);
}
エラー4: loading.tsxやerror.tsxの不適切な配置
ハマりどころ: データフェッチングやレンダリングに時間がかかるルートに対して、適切なloading.tsxやerror.tsxファイルが定義されていない場合、ユーザーは空白の画面やクラッシュを目にすることになります。
回避策: 遅延が予想されるルートには、これらの特殊ファイルを適切なレベルで追加します。loading.tsxは同じセグメント内のデータフェッチング中に表示され、error.tsxはそのセグメントとその子孫で発生したエラーをキャッチします。これにより、ユーザーに適切なフィードバックを提供し、UXを制御できます。
設計上のトレードオフとベストプラクティス
Colocationは強力なパターンですが、常に万能ではありません。設計における考慮事項とベストプラクティスを理解することが重要です。
ベストプラクティス
-
機能ごとのグループ化: コードをタイプ別(例:
components/,utils/)ではなく、機能別(例:dashboard/,settings/)にグループ化します。各ルートフォルダには、そのページ、レイアウト、小さなサポートコンポーネント、テスト、スタイルなどをColocateします。 -
Colocationの活用: ルート固有のロジックは、グローバルな
components/フォルダを散らかすのを避け、関連するコンポーネント、スタイル、ヘルパー関数をルートの隣に配置します。 - Route Groupsの戦略的利用: URLパスに影響を与えずにルートを整理するためにRoute Groupsを使用します。例えば、認証済みユーザー向けのダッシュボードと公開マーケティングページで異なるレイアウトを共有する場合などに有効です。
-
Private Foldersの活用: ルーティングシステムから除外したいファイルやフォルダには、アンダースコアをプレフィックスとして付けます(例:
_components,_lib)。これにより、UIロジックとルーティングロジックを分離し、内部ファイルを一貫して整理できます。 -
Server Componentsをデフォルトとする: パフォーマンス上の利点を最大限に活用するため、可能な限りServer Componentsを使用します。インタラクティブ性が必要な場合にのみ
"use client"をマークします。 -
レイアウトの賢明な利用: ルートレイアウトはグローバルな足場(
html、bodyタグ、グローバルプロバイダー、ヘッダー、フッター)を保持し、ネストされたレイアウトはダッシュボードやユーザープロファイルなどのセクションで再利用可能なシェルを作成します。レイアウトのネストが深くなりすぎないように注意します。 - チームでの規約の文書化と徹底: 構造は人にも関わるため、早期に規約を合意し、文書化します。リンティングやTypeScriptルールを使用して、クライアントとサーバーの境界を強制します。
トレードオフ
-
Colocation vs. グローバルな共有:
- 利点: 特定のルートに関連するコードが近くにあり、見つけやすく、変更の影響範囲が限定的になります。機能が自己完結型になり、認知負荷が軽減されます。
- 欠点: 複数のルートで再利用されるコンポーネントやロジックをどこに配置するかという問題が生じます。すべてをColocateしすぎると、グローバルに再利用すべきものが散らばってしまう可能性があります。
-
解決策: ルート固有のものはColocateし、複数のルートやアプリケーション全体で広く再利用されるものは、
_components、_lib、_hooksなどのPrivate Foldersに配置します。
-
ネストの深さ:
- 利点: ルート構造がURLと密接に一致し、論理的な階層を表現できます。
- 欠点: ネストが深すぎると、どのレイアウトがアクティブであるかを理解するのが難しくなり、ファイルパスが長くなります。
- 解決策: URLが本当に必要とする場合にのみフォルダをネストし、組織化のためにはRoute Groupsを使用します。
-
Server Components vs. Client Componentsの境界:
- 利点: 初期ロード時間の短縮、バンドルサイズの削減、SEOの向上、機密データの安全な処理。
- 欠点: どこで境界線を引くかという判断が難しい場合があります。Client Componentを過剰に使用すると、Server Componentsの利点を失います。
- 解決策: デフォルトはServer Componentsとし、インタラクティブな末端部分にのみClient Componentsを使用します。Server Componentsがデータ読み込みとコンポジションを担当し、Client Componentsがインタラクティブ性を担当するという明確なルールを設けます。
まとめ
Next.js App RouterにおけるColocationは、大規模アプリケーション開発においてコードの保守性と開発効率を高める強力な設計パターンです。機能ごとにファイルを併置し、Route GroupsやPrivate Foldersを戦略的に活用することで、見通しが良く、スケーラブルなプロジェクト構造を実現できます。
Server ComponentsとClient Componentsの役割を明確に理解し、それぞれの特性を最大限に活かすことで、パフォーマンスとインタラクティブ性の両立が可能になります。本記事で紹介した設計判断と実装例、そしてハマりどころと回避策が、Next.js App Routerでの開発の一助となれば幸いです。