T-CREATOR

Nuxt プロジェクトのベストディレクトリ設計

Nuxt プロジェクトのベストディレクトリ設計

Nuxt プロジェクトを開発していて、「どこにファイルを置けばいいの?」と迷った経験はありませんか。適切なディレクトリ構成は、開発効率と保守性を大幅に向上させる重要な要素です。

今回は、Nuxt プロジェクトにおける最適なディレクトリ設計について、初心者の方にもわかりやすく解説していきます。規約に従った構成から、プロジェクト規模に応じた実践的な設計例まで、すぐに使える知識をお届けします。

背景

Nuxt の規約ベースアーキテクチャ

Nuxt は「Convention over Configuration(設定より規約)」の思想に基づいて設計されています。これは、開発者が複雑な設定を行わなくても、決められたディレクトリ構成に従うだけで、自動的に最適化された機能を利用できることを意味します。

例えば、pagesディレクトリに Vue ファイルを配置するだけで、自動的にルーティングが生成されます。この規約ベースのアプローチにより、チーム開発での統一性が保たれ、新しいメンバーも素早くプロジェクトに参加できるのです。

以下の図は、Nuxt の規約ベースアーキテクチャの基本的な仕組みを示しています。

mermaidflowchart TD
    developer[開発者] -->|ファイル配置| directories[規約ディレクトリ]
    directories --> pages[pages/]
    directories --> components[components/]
    directories --> layouts[layouts/]
    directories --> middleware[middleware/]

    pages -->|自動生成| routing[ルーティング]
    components -->|自動インポート| autoImport[コンポーネント自動インポート]
    layouts -->|自動適用| layoutSystem[レイアウトシステム]
    middleware -->|自動実行| middlewareExec[ミドルウェア実行]

    routing --> app[Nuxtアプリケーション]
    autoImport --> app
    layoutSystem --> app
    middlewareExec --> app

図で理解できる要点:

  • 開発者は決められたディレクトリにファイルを配置するだけ
  • Nuxt が自動的に機能を生成・適用
  • 設定ファイルの記述量を大幅に削減

ディレクトリ構成がもたらす恩恵

適切なディレクトリ構成を採用することで、以下のような恩恵を得られます。

恩恵説明具体例
開発効率の向上ファイルの所在が明確になり、開発スピードが向上コンポーネントを探す時間が短縮
保守性の向上機能ごとに整理され、バグ修正や機能追加が容易特定の画面の修正箇所がすぐに特定可能
チーム開発の円滑化統一されたルールにより、誰が見ても理解しやすい新メンバーのオンボーディング時間を短縮
スケーラビリティプロジェクトの成長に合わせて構成を拡張可能機能追加時の影響範囲を限定

特に、Nuxt の自動インポート機能により、componentscomposablesutilsディレクトリ内のファイルは、明示的にインポート文を書かなくても使用できます。これにより、コードの記述量が削減され、開発効率が大幅に向上するのです。

課題

一般的なディレクトリ設計の問題点

多くのプロジェクトで見られる問題点を整理してみましょう。

ファイルの配置場所が不明確

初心者の方がよく遭遇する問題として、「このファイルはどこに置けばいいの?」という迷いがあります。特に以下のようなケースで混乱が生じやすいです。

typescript// ❌ 問題のある配置例
src/
├── components/
│   ├── Header.vue          // グローバルコンポーネント?
│   ├── ProductCard.vue     // 商品専用?共通?
│   └── AdminButton.vue     // 管理画面専用?
├── utils/
│   ├── api.js             // API関連のユーティリティ
│   ├── format.js          // フォーマット関数
│   └── validation.js      // バリデーション
└── pages/
    ├── product.vue        // 商品一覧?詳細?
    └── admin.vue          // 管理画面のトップ?

この例では、各ファイルの用途や適用範囲が名前から判断しにくく、開発者が迷いやすい構成になっています。

機能ごとの境界が曖昧

もう一つの大きな問題は、機能の境界が不明確なことです。以下の図は、よくある問題のある構成を示しています。

mermaidflowchart LR
    subgraph "問題のある構成"
        comp[components/]
        util[utils/]
        page[pages/]

        comp -.->|依存関係が複雑| util
        page -.->|どこにあるか不明| comp
        util -.->|機能境界が曖昧| page
    end

    subgraph "結果"
        confusion[混乱]
        maintenance[保守困難]
        slowDev[開発速度低下]
    end

    comp --> confusion
    util --> maintenance
    page --> slowDev

図で理解できる要点:

  • 機能間の依存関係が複雑化
  • ファイルの所在が特定しにくい
  • 結果として開発効率が低下

スケーラビリティの課題

プロジェクトが成長するにつれて、初期の簡単な構成では対応しきれなくなります。

小規模から中規模への移行時の問題

プロジェクトの成長段階で発生する典型的な問題を見てみましょう。

成長段階ファイル数主な問題影響
小規模10-30 ファイル構成が単純すぎる将来の拡張性を考慮していない
中規模50-100 ファイルディレクトリが肥大化ファイル検索に時間がかかる
大規模100 ファイル以上責務分離が不十分機能追加時の影響範囲が広がる

技術的負債の蓄積

適切な設計を行わないと、以下のような技術的負債が蓄積されます。

typescript// ❌ 技術的負債の例
// components/index.vue - 巨大なコンポーネント
export default {
  name: 'ProductPage',
  data() {
    return {
      products: [],
      categories: [],
      filters: {},
      sorting: {},
      pagination: {},
      // ... 100行以上のデータプロパティ
    };
  },
  methods: {
    // ... 50個以上のメソッド
  },
};

このような巨大なファイルは、保守性を著しく低下させ、バグの温床となります。

解決策

Nuxt 公式推奨ディレクトリ構成

Nuxt では、効率的な開発を行うための標準的なディレクトリ構成が定義されています。以下が基本的な構成です。

csharpnuxt-project/
├── .nuxt/              # ビルド時に自動生成(触らない)
├── assets/             # コンパイルが必要なアセット
├── components/         # Vueコンポーネント(自動インポート)
├── composables/        # コンポーザブル関数(自動インポート)
├── content/            # Nuxt Content用のファイル
├── layouts/            # レイアウトコンポーネント
├── middleware/         # ルートミドルウェア
├── pages/              # ページコンポーネント(自動ルーティング)
├── plugins/            # プラグイン
├── public/             # 静的ファイル(そのまま配信)
├── server/             # サーバーサイドコード
├── utils/              # ユーティリティ関数(自動インポート)
├── app.vue             # メインアプリコンポーネント
├── error.vue           # エラーページ
├── nuxt.config.ts      # Nuxt設定ファイル
└── package.json        # 依存関係とスクリプト

各ディレクトリの役割と配置ルール

それぞれのディレクトリには明確な役割があります。適切に使い分けることで、効率的な開発が可能になります。

pages ディレクトリ - 自動ルーティングの核心

pagesディレクトリは、Nuxt の最も重要な機能の一つである自動ルーティングを担当します。

typescript// pages/index.vue - トップページ(/ にマッピング)
<template>
  <div>
    <h1>ホームページ</h1>
    <p>Nuxtアプリケーションのトップページです</p>
  </div>
</template>

<script setup lang="ts">
// ページ固有のロジック
const title = 'ホームページ'
useSeoMeta({
  title,
  description: 'Nuxtアプリケーションのトップページです'
})
</script>
typescript// pages/products/[id].vue - 動的ルート(/products/123 にマッピング)
<template>
  <div>
    <h1>{{ product.name }}</h1>
    <p>{{ product.description }}</p>
  </div>
</template>

<script setup lang="ts">
// ルートパラメータの取得
const route = useRoute()
const productId = route.params.id

// 商品データの取得
const { data: product } = await $fetch(`/api/products/${productId}`)
</script>

components ディレクトリ - 再利用可能な UI 部品

コンポーネントは機能や用途に応じて整理します。

typescript// components/UI/Button.vue - 基本的なUIコンポーネント
<template>
  <button
    :class="buttonClasses"
    :disabled="disabled"
    @click="$emit('click')"
  >
    <slot />
  </button>
</template>

<script setup lang="ts">
interface Props {
  variant?: 'primary' | 'secondary' | 'danger'
  size?: 'small' | 'medium' | 'large'
  disabled?: boolean
}

const props = withDefaults(defineProps<Props>(), {
  variant: 'primary',
  size: 'medium',
  disabled: false
})

// スタイルクラスの算出
const buttonClasses = computed(() => [
  'btn',
  `btn--${props.variant}`,
  `btn--${props.size}`,
  { 'btn--disabled': props.disabled }
])
</script>

以下の図は、適切なコンポーネント設計の階層構造を示しています。

mermaidflowchart TD
    subgraph "コンポーネント階層"
        ui[UI/ - 基本部品]
        layout[Layout/ - レイアウト部品]
        feature[Feature/ - 機能別部品]

        ui --> button[Button.vue]
        ui --> input[Input.vue]
        ui --> modal[Modal.vue]

        layout --> header[Header.vue]
        layout --> footer[Footer.vue]
        layout --> sidebar[Sidebar.vue]

        feature --> productCard[ProductCard.vue]
        feature --> userProfile[UserProfile.vue]
        feature --> shoppingCart[ShoppingCart.vue]
    end

    subgraph "使用関係"
        layout -.->|使用| ui
        feature -.->|使用| ui
        pages[pages/] -.->|使用| layout
        pages -.->|使用| feature
    end

図で理解できる要点:

  • UI 部品が最下層で最も汎用的
  • 機能別部品が UI 部品を組み合わせて構築
  • ページが各レイヤーのコンポーネントを使用

composables ディレクトリ - ロジックの再利用

Vue 3 の Composition API を活用したロジックの共有を行います。

typescript// composables/useApi.ts - API呼び出し用のコンポーザブル
export const useApi = () => {
  const loading = ref(false);
  const error = ref<string | null>(null);

  const fetchData = async <T>(
    url: string
  ): Promise<T | null> => {
    loading.value = true;
    error.value = null;

    try {
      const data = await $fetch<T>(url);
      return data;
    } catch (err) {
      error.value =
        err instanceof Error
          ? err.message
          : 'エラーが発生しました';
      return null;
    } finally {
      loading.value = false;
    }
  };

  return {
    loading: readonly(loading),
    error: readonly(error),
    fetchData,
  };
};
typescript// composables/useLocalStorage.ts - ローカルストレージ管理
export const useLocalStorage = <T>(
  key: string,
  defaultValue: T
) => {
  const storedValue = process.client
    ? localStorage.getItem(key)
    : null;

  const state = ref<T>(
    storedValue ? JSON.parse(storedValue) : defaultValue
  );

  // 値の変更を監視してローカルストレージに保存
  watch(
    state,
    (newValue) => {
      if (process.client) {
        localStorage.setItem(key, JSON.stringify(newValue));
      }
    },
    { deep: true }
  );

  return state;
};

utils ディレクトリ - 純粋関数とヘルパー

ビジネスロジックに依存しない、純粋な関数を配置します。

typescript// utils/format.ts - フォーマット関数
/**
 * 数値を通貨形式でフォーマット
 */
export const formatCurrency = (
  amount: number,
  currency = 'JPY'
): string => {
  return new Intl.NumberFormat('ja-JP', {
    style: 'currency',
    currency,
  }).format(amount);
};

/**
 * 日付を指定形式でフォーマット
 */
export const formatDate = (
  date: Date | string,
  format = 'YYYY-MM-DD'
): string => {
  const dateObj =
    typeof date === 'string' ? new Date(date) : date;

  const year = dateObj.getFullYear();
  const month = String(dateObj.getMonth() + 1).padStart(
    2,
    '0'
  );
  const day = String(dateObj.getDate()).padStart(2, '0');

  return format
    .replace('YYYY', String(year))
    .replace('MM', month)
    .replace('DD', day);
};

具体例

小規模プロジェクトの構成例

個人開発や小さなチームでのプロジェクトに適した構成を見てみましょう。

csharpsimple-blog/
├── components/
│   ├── Header.vue          # グローバルヘッダー
│   ├── Footer.vue          # グローバルフッター
│   ├── ArticleCard.vue     # 記事カード
│   └── CommentForm.vue     # コメントフォーム
├── composables/
│   ├── useAuth.ts          # 認証関連
│   └── useApi.ts           # API呼び出し
├── layouts/
│   └── default.vue         # デフォルトレイアウト
├── pages/
│   ├── index.vue           # トップページ
│   ├── articles/
│   │   ├── index.vue       # 記事一覧
│   │   └── [slug].vue      # 記事詳細
│   └── auth/
│       ├── login.vue       # ログイン
│       └── register.vue    # 新規登録
├── utils/
│   ├── format.ts           # フォーマット関数
│   └── validation.ts       # バリデーション
└── nuxt.config.ts

小規模プロジェクトでは、シンプルで分かりやすい構成を心がけます。

typescript// components/ArticleCard.vue - 記事カードコンポーネント
<template>
  <article class="article-card">
    <h2 class="article-card__title">
      <NuxtLink :to="`/articles/${article.slug}`">
        {{ article.title }}
      </NuxtLink>
    </h2>
    <p class="article-card__excerpt">
      {{ article.excerpt }}
    </p>
    <div class="article-card__meta">
      <time :datetime="article.publishedAt">
        {{ formatDate(article.publishedAt) }}
      </time>
      <span class="article-card__author">
        {{ article.author.name }}
      </span>
    </div>
  </article>
</template>

<script setup lang="ts">
interface Article {
  slug: string
  title: string
  excerpt: string
  publishedAt: string
  author: {
    name: string
  }
}

interface Props {
  article: Article
}

defineProps<Props>()
</script>

中規模プロジェクトの構成例

複数の機能を持つ Web アプリケーションの構成例です。

csharpecommerce-app/
├── components/
│   ├── UI/                 # 基本的なUIコンポーネント
│   │   ├── Button.vue
│   │   ├── Input.vue
│   │   ├── Modal.vue
│   │   └── Loading.vue
│   ├── Layout/            # レイアウト関連
│   │   ├── Header.vue
│   │   ├── Navigation.vue
│   │   ├── Footer.vue
│   │   └── Sidebar.vue
│   ├── Product/           # 商品関連
│   │   ├── ProductCard.vue
│   │   ├── ProductList.vue
│   │   ├── ProductFilter.vue
│   │   └── ProductDetails.vue
│   ├── Cart/              # カート関連
│   │   ├── CartItem.vue
│   │   ├── CartSummary.vue
│   │   └── CartButton.vue
│   └── User/              # ユーザー関連
│       ├── UserProfile.vue
│       ├── LoginForm.vue
│       └── RegisterForm.vue
├── composables/
│   ├── useAuth.ts         # 認証管理
│   ├── useCart.ts         # カート管理
│   ├── useProducts.ts     # 商品データ管理
│   ├── useLocalStorage.ts # ローカルストレージ
│   └── useApi.ts          # API呼び出し
├── layouts/
│   ├── default.vue        # 通常のレイアウト
│   ├── auth.vue           # 認証ページ用
│   └── admin.vue          # 管理画面用
├── middleware/
│   ├── auth.ts            # 認証チェック
│   └── admin.ts           # 管理者権限チェック
├── pages/
│   ├── index.vue          # トップページ
│   ├── products/
│   │   ├── index.vue      # 商品一覧
│   │   ├── [id].vue       # 商品詳細
│   │   └── category/
│   │       └── [slug].vue # カテゴリ別商品一覧
│   ├── cart/
│   │   ├── index.vue      # カート内容
│   │   └── checkout.vue   # チェックアウト
│   ├── user/
│   │   ├── profile.vue    # プロフィール
│   │   ├── orders.vue     # 注文履歴
│   │   └── settings.vue   # 設定
│   └── auth/
│       ├── login.vue      # ログイン
│       └── register.vue   # 新規登録
├── server/
│   └── api/               # サーバーサイドAPI
│       ├── products.get.ts
│       ├── cart.post.ts
│       └── auth/
│           ├── login.post.ts
│           └── register.post.ts
├── types/
│   ├── product.ts         # 商品関連の型定義
│   ├── user.ts            # ユーザー関連の型定義
│   └── api.ts             # API関連の型定義
└── utils/
    ├── format.ts          # フォーマット関数
    ├── validation.ts      # バリデーション
    └── constants.ts       # 定数定義

中規模プロジェクトでは、機能ごとにディレクトリを分けて整理します。

以下の図は、中規模プロジェクトの機能間の関係性を示しています。

mermaidflowchart TD
    subgraph "ページ層"
        products[products/]
        cart[cart/]
        user[user/]
    end

    subgraph "コンポーネント層"
        productComp[Product/]
        cartComp[Cart/]
        userComp[User/]
        ui[UI/]
    end

    subgraph "ロジック層"
        productLogic[useProducts]
        cartLogic[useCart]
        authLogic[useAuth]
        apiLogic[useApi]
    end

    subgraph "サーバー層"
        serverApi[server/api/]
    end

    products --> productComp
    cart --> cartComp
    user --> userComp

    productComp --> ui
    cartComp --> ui
    userComp --> ui

    productComp --> productLogic
    cartComp --> cartLogic
    userComp --> authLogic

    productLogic --> apiLogic
    cartLogic --> apiLogic
    authLogic --> apiLogic

    apiLogic --> serverApi

図で理解できる要点:

  • 各層が明確に分離されている
  • UI コンポーネントは全機能で共有
  • API 層が統一的にサーバーとやり取り

実装サンプルコード

実際のコードを通じて、適切な構成の利点を確認してみましょう。

カート機能の実装例

typescript// composables/useCart.ts - カート管理のコンポーザブル
interface CartItem {
  id: string;
  name: string;
  price: number;
  quantity: number;
  image: string;
}

export const useCart = () => {
  // ローカルストレージでカート状態を永続化
  const items = useLocalStorage<CartItem[]>(
    'cart-items',
    []
  );

  // 合計金額の算出
  const totalAmount = computed(() => {
    return items.value.reduce((total, item) => {
      return total + item.price * item.quantity;
    }, 0);
  });

  // アイテム数の算出
  const totalItems = computed(() => {
    return items.value.reduce(
      (total, item) => total + item.quantity,
      0
    );
  });

  // カートにアイテムを追加
  const addItem = (product: Omit<CartItem, 'quantity'>) => {
    const existingItem = items.value.find(
      (item) => item.id === product.id
    );

    if (existingItem) {
      existingItem.quantity++;
    } else {
      items.value.push({ ...product, quantity: 1 });
    }
  };

  // アイテムの数量を更新
  const updateQuantity = (id: string, quantity: number) => {
    const item = items.value.find((item) => item.id === id);
    if (item) {
      item.quantity = Math.max(0, quantity);
      if (item.quantity === 0) {
        removeItem(id);
      }
    }
  };

  // アイテムを削除
  const removeItem = (id: string) => {
    const index = items.value.findIndex(
      (item) => item.id === id
    );
    if (index > -1) {
      items.value.splice(index, 1);
    }
  };

  // カートをクリア
  const clearCart = () => {
    items.value = [];
  };

  return {
    items: readonly(items),
    totalAmount,
    totalItems,
    addItem,
    updateQuantity,
    removeItem,
    clearCart,
  };
};
typescript// components/Cart/CartButton.vue - カートボタンコンポーネント
<template>
  <button
    class="cart-button"
    @click="toggleCart"
  >
    <Icon name="shopping-cart" />
    <span class="cart-button__count" v-if="totalItems > 0">
      {{ totalItems }}
    </span>
  </button>
</template>

<script setup lang="ts">
// カート状態を取得
const { totalItems } = useCart()

// カート表示の切り替え
const isCartOpen = ref(false)
const toggleCart = () => {
  isCartOpen.value = !isCartOpen.value
}
</script>
typescript// pages/products/[id].vue - 商品詳細ページ
<template>
  <div class="product-detail">
    <div class="product-detail__image">
      <img :src="product.image" :alt="product.name" />
    </div>

    <div class="product-detail__info">
      <h1 class="product-detail__title">{{ product.name }}</h1>
      <p class="product-detail__price">
        {{ formatCurrency(product.price) }}
      </p>
      <p class="product-detail__description">
        {{ product.description }}
      </p>

      <UIButton
        @click="handleAddToCart"
        :disabled="loading"
      >
        {{ loading ? 'カートに追加中...' : 'カートに追加' }}
      </UIButton>
    </div>
  </div>
</template>

<script setup lang="ts">
// ルートパラメータから商品IDを取得
const route = useRoute()
const productId = route.params.id as string

// 商品データの取得
const { data: product, pending } = await useFetch(`/api/products/${productId}`)

// カート機能の利用
const { addItem } = useCart()

// カートに追加する処理
const loading = ref(false)
const handleAddToCart = async () => {
  if (!product.value) return

  loading.value = true

  try {
    addItem({
      id: product.value.id,
      name: product.value.name,
      price: product.value.price,
      image: product.value.image
    })

    // 成功通知
    await navigateTo('/cart')
  } catch (error) {
    console.error('カートへの追加に失敗しました:', error)
  } finally {
    loading.value = false
  }
}

// SEO設定
if (product.value) {
  useSeoMeta({
    title: product.value.name,
    description: product.value.description
  })
}
</script>

このサンプルコードでは、以下の点で適切な設計を実現しています。

設計原則実装方法利点
関心の分離カートロジックをコンポーザブルに分離テストしやすく、再利用可能
単一責任各コンポーネントが明確な役割を持つ保守性が高い
データの流れ状態管理が一元化されているデータの整合性を保てる
型安全性TypeScript で型定義を徹底バグを早期発見できる

まとめ

ベストプラクティスの要点

Nuxt プロジェクトにおけるディレクトリ設計のベストプラクティスをまとめます。

原則具体的な実践方法効果
規約に従うNuxt の標準ディレクトリ構成を基本とする学習コストの削減、チーム開発の効率化
機能で分ける関連するファイルを同じディレクトリにまとめる変更時の影響範囲を限定
層で分離UI、ロジック、データアクセスを分離テストしやすく、保守性が向上
命名を統一一貫したファイル・ディレクトリ命名規則可読性とメンテナンス性が向上
段階的拡張プロジェクトの成長に合わせて構成を発展技術的負債の蓄積を防止

初心者向けのチェックリスト

以下のチェックリストを参考に、プロジェクトの構成を見直してみてください。

  • pagesディレクトリでルーティングを整理できているか
  • componentsが適切な粒度で分割されているか
  • 共通ロジックがcomposablesに抽出されているか
  • ユーティリティ関数がutilsに分類されているか
  • 型定義がtypesディレクトリで管理されているか
  • ファイル名が機能を表現しているか
  • ディレクトリの階層が深すぎないか(3 階層まで推奨)

継続的な改善のポイント

良いディレクトリ設計は一度作って終わりではありません。継続的な改善が重要です。

定期的な見直し

プロジェクトの成長に合わせて、定期的にディレクトリ構成を見直しましょう。

typescript// 改善例:巨大なコンポーネントの分割
// ❌ 改善前:1つの巨大なコンポーネント
// components/ProductPage.vue (300行)

// ✅ 改善後:機能ごとに分割
// components/Product/ProductDetails.vue
// components/Product/ProductImages.vue
// components/Product/ProductReviews.vue
// components/Product/ProductRecommendations.vue

チーム内でのルール共有

チーム開発では、ディレクトリ設計のルールを文書化し、共有することが重要です。

markdown# プロジェクト構成ルール

# ファイル命名規則

- コンポーネント: PascalCase (例: UserProfile.vue)
- コンポーザブル: camelCase (例: useAuth.ts)
- ユーティリティ: camelCase (例: formatDate.ts)

# ディレクトリ分割基準

- components: 20 ファイル以上で機能別サブディレクトリを作成
- pages: 機能ごとにサブディレクトリを作成
- composables: 機能別に分類(例: auth/, api/, ui/)

適切なディレクトリ設計は、開発効率の向上だけでなく、プロジェクトの長期的な成功にも大きく貢献します。今回紹介したパターンを参考に、皆さんのプロジェクトに最適な構成を見つけてください。

継続的な改善を心がけることで、より良いコードベースを構築していけるでしょう。

関連リンク