Documentation Next.js

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">
          &copy; 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 DesignUI重視のプロジェクトコンポーネントの再利用性が高い

プロジェクトの規模や要件に応じて適切な構成を選択し、チーム全体でルールを共有することで、効率的な開発を実現しましょう。

参考文献

円