Next.jsプロジェクトを効率的に開発・保守するためには、適切なフォルダ構成が欠かせません。この記事では、小規模から大規模プロジェクトまで対応できるフォルダ構成のベストプラクティスを解説します。App Router(Next.js 13以降)を中心に、機能ベースの設計やAtomic Designの導入方法、実践的なコード例を交えて紹介します。
Next.jsプロジェクトの基本フォルダ構成
Next.jsでは、ファイルベースのルーティング(File-based Routing)が重要な役割を果たしています。ファイルを特定のディレクトリに配置するだけで、自動的にルートが生成される仕組みです。
基本的なフォルダ構成
my-nextjs-app/
├── src/ # ソースコードのルートディレクトリ
│ ├── app/ # App Router(Next.js 13+)のルーティング
│ │ ├── layout.tsx # ルートレイアウト(必須)
│ │ ├── page.tsx # トップページ(/)
│ │ ├── globals.css # グローバルスタイル
│ │ └── about/
│ │ └── page.tsx # Aboutページ(/about)
│ ├── components/ # 再利用可能なUIコンポーネント
│ │ ├── ui/ # 基本UI部品(Button, Input等)
│ │ └── layout/ # レイアウト関連(Header, Footer等)
│ ├── lib/ # ユーティリティ関数やAPI関連
│ │ ├── utils.ts # 汎用ユーティリティ
│ │ └── api.ts # API呼び出し関数
│ ├── hooks/ # カスタムフック
│ ├── types/ # TypeScript型定義
│ └── styles/ # スタイル関連ファイル
├── public/ # 静的ファイル(画像、フォント等)
├── next.config.js # Next.js設定
├── tsconfig.json # TypeScript設定
└── package.json
各ディレクトリの役割
| ディレクトリ | 役割 |
|---|---|
src/app/ | App Routerのページとルーティング |
src/components/ | 再利用可能なReactコンポーネント |
src/lib/ | ユーティリティ関数、API関連コード |
src/hooks/ | カスタムReactフック |
src/types/ | TypeScript型定義ファイル |
public/ | 静的アセット(画像、favicon等) |
App Routerのディレクトリ構造
Next.js 13以降で導入されたApp Routerでは、ディレクトリ構造がそのままURLパスに対応します。
基本的なルーティング例
src/app/
├── layout.tsx # 全ページ共通のレイアウト
├── page.tsx # / (トップページ)
├── loading.tsx # ローディングUI
├── error.tsx # エラーUI
├── not-found.tsx # 404ページ
├── blog/
│ ├── page.tsx # /blog
│ └── [slug]/
│ └── page.tsx # /blog/記事スラッグ(動的ルート)
├── dashboard/
│ ├── layout.tsx # ダッシュボード専用レイアウト
│ ├── page.tsx # /dashboard
│ └── settings/
│ └── page.tsx # /dashboard/settings
└── api/
└── users/
└── route.ts # API Route: /api/users
ルートレイアウトの実装例
// src/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
// フォントの最適化
const inter = Inter({ subsets: ['latin'] });
// メタデータの定義
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App', // 子ページのタイトル形式
},
description: 'Next.jsで構築されたアプリケーション',
};
// ルートレイアウトコンポーネント
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body className={inter.className}>
{/* 全ページ共通のヘッダー */}
<header className="bg-gray-800 text-white p-4">
<nav>My App</nav>
</header>
{/* ページコンテンツ */}
<main>{children}</main>
{/* 全ページ共通のフッター */}
<footer className="bg-gray-100 p-4 text-center">
© 2024 My App
</footer>
</body>
</html>
);
}
機能ベースのファイル構成
スケーラブルなプロジェクトでは、コードを機能(Feature)ごとに整理するのが効果的です。この方法は「Feature-based Architecture」や「Vertical Slice Architecture」とも呼ばれます。
機能ベースのフォルダ構成例
src/
├── app/ # ルーティング(薄く保つ)
│ ├── layout.tsx
│ ├── page.tsx
│ ├── auth/
│ │ └── login/
│ │ └── page.tsx
│ └── dashboard/
│ └── page.tsx
├── features/ # 機能別モジュール
│ ├── auth/ # 認証機能
│ │ ├── components/
│ │ │ ├── LoginForm.tsx
│ │ │ ├── RegisterForm.tsx
│ │ │ └── AuthButton.tsx
│ │ ├── hooks/
│ │ │ └── useAuth.ts
│ │ ├── api/
│ │ │ └── auth.ts
│ │ ├── types/
│ │ │ └── index.ts
│ │ └── index.ts # 公開APIのエクスポート
│ ├── users/ # ユーザー管理機能
│ │ ├── components/
│ │ │ ├── UserProfile.tsx
│ │ │ └── UserList.tsx
│ │ ├── hooks/
│ │ │ └── useUser.ts
│ │ ├── api/
│ │ │ └── users.ts
│ │ └── index.ts
│ └── products/ # 商品管理機能
│ ├── components/
│ ├── hooks/
│ ├── api/
│ └── index.ts
├── shared/ # 共有リソース
│ ├── components/ # 共通UIコンポーネント
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ └── Modal.tsx
│ ├── hooks/ # 共通フック
│ │ ├── useLocalStorage.ts
│ │ └── useMediaQuery.ts
│ ├── lib/ # ユーティリティ
│ │ ├── fetcher.ts
│ │ └── formatters.ts
│ └── types/ # 共通型定義
│ └── common.ts
└── config/ # 設定ファイル
├── constants.ts
└── env.ts
機能モジュールの実装例
// src/features/auth/types/index.ts
// 認証機能で使用する型定義
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'user';
createdAt: Date;
}
export interface LoginCredentials {
email: string;
password: string;
}
export interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
}
// src/features/auth/hooks/useAuth.ts
// 認証状態を管理するカスタムフック
'use client';
import { useState, useCallback } from 'react';
import { login, logout } from '../api/auth';
import type { User, LoginCredentials, AuthState } from '../types';
export function useAuth() {
const [state, setState] = useState<AuthState>({
user: null,
isAuthenticated: false,
isLoading: false,
});
// ログイン処理
const handleLogin = useCallback(async (credentials: LoginCredentials) => {
setState(prev => ({ ...prev, isLoading: true }));
try {
const user = await login(credentials);
setState({
user,
isAuthenticated: true,
isLoading: false,
});
return { success: true };
} catch (error) {
setState(prev => ({ ...prev, isLoading: false }));
return {
success: false,
error: error instanceof Error ? error.message : 'ログインに失敗しました'
};
}
}, []);
// ログアウト処理
const handleLogout = useCallback(async () => {
await logout();
setState({
user: null,
isAuthenticated: false,
isLoading: false,
});
}, []);
return {
...state,
login: handleLogin,
logout: handleLogout,
};
}
// src/features/auth/components/LoginForm.tsx
// ログインフォームコンポーネント
'use client';
import { useState } from 'react';
import { useAuth } from '../hooks/useAuth';
import { Button } from '@/shared/components/Button';
import { Input } from '@/shared/components/Input';
export function LoginForm() {
const { login, isLoading } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
const result = await login({ email, password });
if (!result.success) {
setError(result.error || 'ログインに失敗しました');
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md mx-auto">
<div>
<label htmlFor="email" className="block text-sm font-medium">
メールアドレス
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="example@email.com"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
パスワード
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="パスワードを入力"
/>
</div>
{error && (
<p className="text-red-500 text-sm">{error}</p>
)}
<Button type="submit" disabled={isLoading}>
{isLoading ? 'ログイン中...' : 'ログイン'}
</Button>
</form>
);
}
// src/features/auth/index.ts
// 機能モジュールの公開API
// コンポーネント
export { LoginForm } from './components/LoginForm';
export { RegisterForm } from './components/RegisterForm';
export { AuthButton } from './components/AuthButton';
// フック
export { useAuth } from './hooks/useAuth';
// 型
export type { User, LoginCredentials, AuthState } from './types';
Atomic Designの導入
Atomic Designは、UIコンポーネントを階層的に分類する設計手法です。Brad Frost氏が提唱したこの概念では、コンポーネントを5つの階層に分類します。
Atomic Designの階層
| 階層 | 説明 | 例 |
|---|---|---|
| Atoms | 最小単位のUI要素 | Button, Input, Label, Icon |
| Molecules | 複数のAtomsの組み合わせ | SearchBox, FormField |
| Organisms | より複雑なUIブロック | Header, LoginForm, ProductCard |
| Templates | ページのレイアウト構造 | DashboardLayout |
| Pages | 実際のページ | HomePage, AboutPage |
Atomic Designを利用した構造
src/
├── components/
│ ├── atoms/ # 最小単位のコンポーネント
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.module.css
│ │ │ └── index.ts
│ │ ├── Input/
│ │ │ ├── Input.tsx
│ │ │ └── index.ts
│ │ ├── Label/
│ │ │ └── Label.tsx
│ │ └── Icon/
│ │ └── Icon.tsx
│ ├── molecules/ # Atomsの組み合わせ
│ │ ├── FormField/
│ │ │ ├── FormField.tsx
│ │ │ └── index.ts
│ │ ├── SearchBox/
│ │ │ └── SearchBox.tsx
│ │ └── NavItem/
│ │ └── NavItem.tsx
│ ├── organisms/ # 複雑なUIブロック
│ │ ├── Header/
│ │ │ ├── Header.tsx
│ │ │ └── index.ts
│ │ ├── SignUpForm/
│ │ │ └── SignUpForm.tsx
│ │ └── ProductCard/
│ │ └── ProductCard.tsx
│ └── templates/ # ページレイアウト
│ ├── MainLayout/
│ │ └── MainLayout.tsx
│ └── DashboardLayout/
│ └── DashboardLayout.tsx
└── app/
├── page.tsx
└── signup/
└── page.tsx
Atomic Designの実装例
// src/components/atoms/Button/Button.tsx
// Atom: ボタンコンポーネント
import { forwardRef, type ButtonHTMLAttributes } from 'react';
// ボタンのバリエーション定義
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
}
// バリエーション別のスタイル
const variantStyles: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50',
ghost: 'text-blue-600 hover:bg-blue-50',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'primary', size = 'md', isLoading, children, className = '', disabled, ...props }, ref) => {
return (
<button
ref={ref}
className={`
inline-flex items-center justify-center
rounded-md font-medium
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg className="animate-spin -ml-1 mr-2 h-4 w-4\" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
)}
{children}
</button>
);
}
);
Button.displayName = 'Button';
// src/components/molecules/FormField/FormField.tsx
// Molecule: フォームフィールド(Label + Input + エラーメッセージ)
import { Label } from '@/components/atoms/Label';
import { Input } from '@/components/atoms/Input';
interface FormFieldProps {
label: string;
name: string;
type?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
error?: string;
required?: boolean;
placeholder?: string;
}
export function FormField({
label,
name,
type = 'text',
value,
onChange,
error,
required,
placeholder,
}: FormFieldProps) {
return (
<div className="space-y-1">
<Label htmlFor={name} required={required}>
{label}
</Label>
<Input
id={name}
name={name}
type={type}
value={value}
onChange={onChange}
required={required}
placeholder={placeholder}
error={!!error}
/>
{error && (
<p className="text-red-500 text-sm mt-1">{error}</p>
)}
</div>
);
}
// src/components/organisms/SignUpForm/SignUpForm.tsx
// Organism: サインアップフォーム
'use client';
import { useState } from 'react';
import { FormField } from '@/components/molecules/FormField';
import { Button } from '@/components/atoms/Button';
interface FormData {
name: string;
email: string;
password: string;
confirmPassword: string;
}
interface FormErrors {
name?: string;
email?: string;
password?: string;
confirmPassword?: string;
}
export function SignUpForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
password: '',
confirmPassword: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// バリデーション関数
const validate = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = '名前を入力してください';
}
if (!formData.email.includes('@')) {
newErrors.email = '有効なメールアドレスを入力してください';
}
if (formData.password.length < 8) {
newErrors.password = 'パスワードは8文字以上で入力してください';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'パスワードが一致しません';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
setIsSubmitting(true);
try {
// API呼び出し処理
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('登録成功:', formData);
} catch (error) {
console.error('登録エラー:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4 max-w-md mx-auto p-6">
<h2 className="text-2xl font-bold text-center mb-6">アカウント登録</h2>
<FormField
label="名前"
name="name"
value={formData.name}
onChange={handleChange}
error={errors.name}
required
placeholder="山田 太郎"
/>
<FormField
label="メールアドレス"
name="email"
type="email"
value={formData.email}
onChange={handleChange}
error={errors.email}
required
placeholder="example@email.com"
/>
<FormField
label="パスワード"
name="password"
type="password"
value={formData.password}
onChange={handleChange}
error={errors.password}
required
placeholder="8文字以上"
/>
<FormField
label="パスワード(確認)"
name="confirmPassword"
type="password"
value={formData.confirmPassword}
onChange={handleChange}
error={errors.confirmPassword}
required
placeholder="パスワードを再入力"
/>
<Button type="submit" isLoading={isSubmitting} className="w-full">
登録する
</Button>
</form>
);
}
APIディレクトリの活用
Next.jsのAPI Routes(Route Handlers)を使用すると、サーバーレスAPIを簡単に構築できます。App Routerではroute.tsファイルを使用します。
API Routesの構成例
src/app/api/
├── auth/
│ ├── login/
│ │ └── route.ts # POST /api/auth/login
│ ├── logout/
│ │ └── route.ts # POST /api/auth/logout
│ └── me/
│ └── route.ts # GET /api/auth/me
├── users/
│ ├── route.ts # GET, POST /api/users
│ └── [id]/
│ └── route.ts # GET, PUT, DELETE /api/users/:id
└── products/
├── route.ts
└── [id]/
└── route.ts
API Routeの実装例
// src/app/api/users/route.ts
// ユーザー一覧取得・新規作成API
import { NextRequest, NextResponse } from 'next/server';
// 型定義
interface User {
id: string;
name: string;
email: string;
createdAt: string;
}
// モックデータ(実際にはデータベースから取得)
const users: User[] = [
{ id: '1', name: '山田太郎', email: 'yamada@example.com', createdAt: '2024-01-01' },
{ id: '2', name: '鈴木花子', email: 'suzuki@example.com', createdAt: '2024-01-02' },
];
// GET /api/users - ユーザー一覧取得
export async function GET(request: NextRequest) {
try {
// クエリパラメータの取得
const searchParams = request.nextUrl.searchParams;
const limit = parseInt(searchParams.get('limit') || '10');
const offset = parseInt(searchParams.get('offset') || '0');
// ページネーション処理
const paginatedUsers = users.slice(offset, offset + limit);
return NextResponse.json({
data: paginatedUsers,
total: users.length,
limit,
offset,
});
} catch (error) {
return NextResponse.json(
{ error: 'ユーザー一覧の取得に失敗しました' },
{ status: 500 }
);
}
}
// POST /api/users - ユーザー新規作成
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// バリデーション
if (!body.name || !body.email) {
return NextResponse.json(
{ error: '名前とメールアドレスは必須です' },
{ status: 400 }
);
}
// 新規ユーザー作成
const newUser: User = {
id: String(users.length + 1),
name: body.name,
email: body.email,
createdAt: new Date().toISOString(),
};
users.push(newUser);
return NextResponse.json(newUser, { status: 201 });
} catch (error) {
return NextResponse.json(
{ error: 'ユーザーの作成に失敗しました' },
{ status: 500 }
);
}
}
// src/app/api/users/[id]/route.ts
// 個別ユーザーの取得・更新・削除API
import { NextRequest, NextResponse } from 'next/server';
interface RouteParams {
params: {
id: string;
};
}
// GET /api/users/:id - ユーザー詳細取得
export async function GET(request: NextRequest, { params }: RouteParams) {
const { id } = params;
// ユーザー検索(実際にはデータベースから取得)
const user = { id, name: '山田太郎', email: 'yamada@example.com' };
if (!user) {
return NextResponse.json(
{ error: 'ユーザーが見つかりません' },
{ status: 404 }
);
}
return NextResponse.json(user);
}
// PUT /api/users/:id - ユーザー更新
export async function PUT(request: NextRequest, { params }: RouteParams) {
const { id } = params;
const body = await request.json();
// 更新処理(実際にはデータベースを更新)
const updatedUser = {
id,
name: body.name,
email: body.email,
updatedAt: new Date().toISOString(),
};
return NextResponse.json(updatedUser);
}
// DELETE /api/users/:id - ユーザー削除
export async function DELETE(request: NextRequest, { params }: RouteParams) {
const { id } = params;
// 削除処理(実際にはデータベースから削除)
return NextResponse.json(
{ message: `ユーザー ${id} を削除しました` },
{ status: 200 }
);
}
パス エイリアスの設定
TypeScriptのパスエイリアスを設定すると、インポート文が簡潔になり、ディレクトリ構造の変更にも柔軟に対応できます。
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/features/*": ["./src/features/*"],
"@/shared/*": ["./src/shared/*"],
"@/lib/*": ["./src/lib/*"],
"@/types/*": ["./src/types/*"]
}
}
}
// パスエイリアスを使用したインポート例
// Before(相対パス)
import { Button } from '../../../components/atoms/Button';
import { useAuth } from '../../features/auth/hooks/useAuth';
// After(パスエイリアス)
import { Button } from '@/components/atoms/Button';
import { useAuth } from '@/features/auth/hooks/useAuth';
まとめ
Next.jsでのプロジェクトのフォルダ構成は、スケーラビリティやメンテナンス性を高めるための重要な要素です。主なポイントをまとめると以下のようになります。
| 設計パターン | 適したプロジェクト規模 | メリット |
|---|---|---|
| 基本構成 | 小規模 | シンプルで学習コストが低い |
| 機能ベース | 中〜大規模 | 機能の独立性が高く、チーム開発に適する |
| Atomic Design | UI重視のプロジェクト | コンポーネントの再利用性が高い |
プロジェクトの規模や要件に応じて適切な構成を選択し、チーム全体でルールを共有することで、効率的な開発を実現しましょう。
参考文献
- Next.js - Project Structure - Next.js公式ドキュメント
- Next.js - App Router - App Routerの詳細ガイド
- Next.js - Route Handlers - API Routesの実装方法
- Atomic Design by Brad Frost - Atomic Designの提唱者による解説
- Bulletproof React - スケーラブルなReactアプリケーションアーキテクチャ