第2回:実践的なNuxt3ルーティング設計 – 大規模アプリケーションに備える

はじめに

前回の記事では、Nuxt3の開発環境構築とプロジェクトの基本設定について解説しました。

今回は、実際のアプリケーション開発で最も重要な要素の一つとなる「ルーティング設計」にフォーカスを当てていきます。

Nuxt3の特徴的な機能である「ファイルベースルーティング」。

シンプルなアプリケーションであれば、ファイルを配置するだけで直感的にルーティングを実現できる便利な機能です。

しかし、実際の開発現場では、認証・認可、エラーハンドリング、パフォーマンス最適化など、考慮すべき要素が数多く存在します。

「認証が必要なページとそうでないページを分けたい」「APIエラーを適切にハンドリングしたい」「将来の機能追加を見据えたディレクトリ構成にしたい」――。

このような実務での要件に応えるためには、単なるファイルベースルーティングの理解を超えた、体系的なルーティング設計の知識が必要です。

本記事では、Nuxt3のルーティング機能を最大限に活用しながら、実務で必要となる具体的な実装手法とベストプラクティスを紹介していきます。

1. Nuxt3のルーティング基礎

Nuxt3のルーティングシステムは、その直感的な設計で多くの開発者に支持されています。しかし、実務で活用するためには、基本的な機能を深く理解し、効果的に組み合わせる必要があります。

ファイルベースルーティングの仕組み

Nuxt3では、pages/ディレクトリ配下のファイル構造がそのままURLパスに反映されます。

pages/
├── index.vue         # / (トップページ)
├── about.vue         # /about
├── posts/
│   ├── index.vue     # /posts (投稿一覧)
│   ├── [id].vue      # /posts/[id] (投稿詳細)
│   └── create.vue    # /posts/create (投稿作成)
└── users/
     ├── [id]/
     │   ├── index.vue # /users/[id] (ユーザー詳細)
     │   └── edit.vue  # /users/[id]/edit (ユーザー編集)
     └── profile.vue   # /users/profile (プロフィール)

動的ルーティングの実装

実際のアプリケーションでよく使用する動的ルーティングの実装例を見ていきましょう:

vue
<!-- pages/posts/[id].vue -->
<script setup lang="ts">
// ルートパラメータの型安全な取得
const route = useRoute()
const postId = computed(() => route.params.id as string)

// データの取得
const { data: post } = await useFetch(`/api/posts/${postId.value}`)

// バリデーション
definePageMeta({
  validate: async (route) => {
    return /^\d+$/.test(route.params.id as string)
  }
})
</script>

<template>
  <div v-if="post">
    <h1>{{ post.title }}</h1>
    <p>{{ post.content }}</p>
  </div>
</template>

ネストされたルーティングの活用

複雑なUIを実現するために、ネストされたルーティングを使用できます:

vue
<!-- pages/users/[id]/index.vue -->
<script setup lang="ts">
definePageMeta({
  layout: 'user-profile',
})

const route = useRoute()
const userId = computed(() => route.params.id as string)
</script>

<template>
  <div class="user-profile-layout">
    <!-- サイドナビゲーション -->
    <nav class="side-nav">
      <NuxtLink :to="`/users/${userId}/settings`">
        Settings
      </NuxtLink>
      <NuxtLink :to="`/users/${userId}/security`">
        Security
      </NuxtLink>
    </nav>

    <!-- メインコンテンツ -->
    <main>
      <NuxtPage />
    </main>
  </div>
</template>

ルートパラメータの型安全な取り扱い

TypeScriptの恩恵を最大限に受けるために、ルートパラメータの型定義を行います:

typescript
// types/route.d.ts
declare module '#app' {
  interface PageMeta {
    auth?: boolean
    roles?: string[]
  }
  
  interface RouteParams {
    id?: string
    slug?: string
  }
}

export {}

このような基本的な実装を土台として、次のセクションでは実務で重要となる認証・認可の実装について詳しく見ていきましょう。

これらの基礎的な機能を理解することで、より複雑なルーティング要件にも対応できるようになります。

2. Nuxt3で認証・認可の実装

実務のアプリケーション開発において、認証・認可の実装は最も重要な要件の一つです。

Nuxt3では、ミドルウェアとページメタ情報を組み合わせることで、堅牢な認証システムを構築できます。

ミドルウェアを使った認証フロー

まず、認証状態を管理するためのcomposableを作成します:

typescript
// composables/useAuth.ts
export const useAuth = () => {
  const user = useState('user', () => null)
  const isAuthenticated = computed(() => !!user.value)

  const login = async (credentials: Credentials) => {
    try {
      const response = await $fetch('/api/auth/login', {
        method: 'POST',
        body: credentials,
      })
      user.value = response.user
      return { success: true }
    } catch (error) {
      return { success: false, error }
    }
  }

  const logout = async () => {
    await $fetch('/api/auth/logout', { method: 'POST' })
    user.value = null
    navigateTo('/login')
  }

  return {
    user,
    isAuthenticated,
    login,
    logout,
  }
}

次に、認証ミドルウェアを実装します:

typescript
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const { isAuthenticated } = useAuth()
  const publicPages = ['/login', '/register', '/forgot-password']

  if (!isAuthenticated && !publicPages.includes(to.path)) {
    return navigateTo('/login', {
      replace: true,
      query: { redirect: to.fullPath }
    })
  }
})

ロールベースのアクセス制御

より細かなアクセス制御のために、ロールベースの認可システムを実装します:

typescript
// middleware/role.ts
export default defineNuxtRouteMiddleware((to) => {
  const { user } = useAuth()
  const requiredRoles = to.meta.roles as string[] | undefined

  if (requiredRoles && !requiredRoles.some(role => user.value?.roles.includes(role))) {
    throw createError({
      statusCode: 403,
      message: 'Access denied',
    })
  }
})

これをページで使用する例:

vue
<!-- pages/admin/dashboard.vue -->
<script setup lang="ts">
definePageMeta({
  middleware: ['auth', 'role'],
  roles: ['admin', 'manager']
})
</script>

<template>
  <div class="admin-dashboard">
    <h1>Admin Dashboard</h1>
    <!-- 管理者向けコンテンツ -->
  </div>
</template>

認証状態の永続化

ユーザー体験を向上させるために、認証状態を永続化します:

typescript
// plugins/auth.ts
export default defineNuxtPlugin(async () => {
  const { user } = useAuth()

  // ページ読み込み時に認証状態を復元
  if (!user.value) {
    try {
      const response = await $fetch('/api/auth/me')
      user.value = response.user
    } catch (error) {
      // セッションが無効な場合は何もしない
    }
  }

  // クライアントサイドのナビゲーションをインターセプト
  addRouteMiddleware('auth', (to) => {
    const { isAuthenticated } = useAuth()
    // ... 認証チェックのロジック
  }, { global: true })
})

このように実装することで、以下のような堅牢な認証・認可システムが実現できます:

  1. 未認証ユーザーの適切なリダイレクト
  2. ロールベースのきめ細かなアクセス制御
  3. リロード時の認証状態の維持
  4. セキュアなルーティング制御

3. Nuxt3でエラーハンドリング

Nuxt3アプリケーションにおいて、適切なエラーハンドリングは優れたユーザー体験を提供するための重要な要素です。特に大規模なアプリケーションでは、様々なエラーケースに対して一貫性のある方法で対応する必要があります。

カスタムエラーページの作成

まず、Nuxt3のエラーページをカスタマイズして、ユーザーフレンドリーなエラー表示を実装します:

vue
<!-- error.vue -->
<script setup lang="ts">
const props = defineProps({
  error: Object
})

const handleError = () => {
  clearError({ redirect: '/' })
}
</script>

<template>
  <div class="error-container min-h-screen flex items-center justify-center">
    <div class="text-center">
      <h1 class="text-4xl font-bold mb-4">
        {{ error?.statusCode === 404 ? 'ページが見つかりません' : 'エラーが発生しました' }}
      </h1>
      
      <p class="text-gray-600 mb-8">
        {{ error?.message || 'アプリケーションでエラーが発生しました。' }}
      </p>

      <div class="space-x-4">
        <button
          @click="handleError"
          class="bg-blue-500 text-white px-6 py-2 rounded-md hover:bg-blue-600"
        >
          トップページに戻る
        </button>
      </div>
    </div>
  </div>
</template>

API エラーの統一的な処理

API通信時のエラーを一元的に管理するためのcomposableを作成します:

typescript
// composables/useApiError.ts
interface ApiError {
  statusCode: number
  message: string
  errors?: Record<string, string[]>
}

export const useApiError = () => {
  const handleApiError = (error: unknown) => {
    // エラーの型を判別
    const apiError = error as ApiError
    
    // ステータスコードに応じた処理
    switch (apiError.statusCode) {
      case 401:
        const auth = useAuth()
        auth.logout()
        break
      case 403:
        showError({
          statusCode: 403,
          message: '権限がありません'
        })
        break
      case 422:
        // バリデーションエラーの処理
        return apiError.errors
      default:
        showError({
          statusCode: apiError.statusCode || 500,
          message: apiError.message || '予期せぬエラーが発生しました'
        })
    }
  }

  return {
    handleApiError
  }
}

エッジケースの処理

アプリケーションの信頼性を高めるために、エッジケースも適切に処理します:

typescript
// plugins/error-handler.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.vueApp.config.errorHandler = (error, instance, info) => {
    // 未処理のエラーをキャッチ
    console.error('Vue Error:', error)
    console.error('Component:', instance)
    console.error('Error Info:', info)

    // エラー追跡サービスへの送信
    // trackError(error, { component: instance?.$options.name, info })

    // ユーザーへの通知
    const { $toast } = useNuxtApp()
    $toast.error('予期せぬエラーが発生しました')
  }

  // グローバルなエラーハンドリング
  window.addEventListener('unhandledrejection', (event) => {
    console.error('Unhandled Promise Rejection:', event.reason)
    // エラー追跡サービスへの送信
  })
})

実践的なエラーハンドリングの例

実際のコンポーネントでの使用例を見てみましょう:

vue
<!-- pages/users/[id].vue -->
<script setup lang="ts">
const route = useRoute()
const { handleApiError } = useApiError()
const loading = ref(false)
const user = ref(null)

const fetchUser = async () => {
  loading.value = true
  try {
    const response = await $fetch(`/api/users/${route.params.id}`)
    user.value = response.data
  } catch (error) {
    handleApiError(error)
  } finally {
    loading.value = false
  }
}

// ページ読み込み時にデータを取得
onMounted(fetchUser)
</script>

このように体系的なエラーハンドリングを実装することで、以下のような利点が得られます:

  1. 一貫性のあるエラー表示
  2. デバッグのしやすさ
  3. ユーザー体験の向上
  4. メンテナンス性の確保

4. Nuxt3のパフォーマンス最適化

Nuxt3は優れたパフォーマンスを実現するための機能を多く備えていますが、大規模なアプリケーションでは適切な最適化が必要です。ここでは、ルーティングに関連するパフォーマンス最適化の手法について、実践的な実装例とともに解説していきます。

ルートベースのコード分割

Nuxt3では、デフォルトでページ単位のコード分割が行われますが、より細かい制御が必要な場合があります:

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  // チャンクサイズの最適化
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            'group/admin': [
              './pages/admin/dashboard.vue',
              './pages/admin/users.vue'
            ],
            'group/auth': [
              './pages/login.vue',
              './pages/register.vue'
            ]
          }
        }
      }
    }
  }
})

実際のコンポーネントでの遅延ロードの例:

vue
<!-- pages/dashboard.vue -->
<script setup lang="ts">
// 大きなチャートコンポーネントを遅延ロード
const DashboardChart = defineAsyncComponent(() => 
  import('~/components/dashboard/Chart.vue')
)
</script>

<template>
  <div class="dashboard">
    <Suspense>
      <DashboardChart />
      <template #fallback>
        <div class="loading-skeleton">
          <!-- ローディングUI -->
        </div>
      </template>
    </Suspense>
  </div>
</template>

プリフェッチの制御

ユーザー体験を向上させるための賢明なプリフェッチ戦略:

typescript
// composables/useSmartPrefetch.ts
export const useSmartPrefetch = () => {
  const prefetchLinks = () => {
    const links = document.querySelectorAll('a[href^="/"]')
    let observer: IntersectionObserver

    const handleIntersection = (entries: IntersectionObserverEntry[]) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const link = entry.target as HTMLAnchorElement
          const href = link.getAttribute('href')
          if (href) {
            // プリフェッチの実行
            prefetchRoute(href)
            // 一度プリフェッチしたら監視を解除
            observer.unobserve(link)
          }
        }
      })
    }

    observer = new IntersectionObserver(handleIntersection, {
      rootMargin: '50px'
    })

    links.forEach(link => observer.observe(link))

    return () => observer.disconnect()
  }

  return {
    prefetchLinks
  }
}

キャッシュ戦略

効率的なデータ取得のためのキャッシュ制御:

typescript
// composables/useCachedData.ts
export const useCachedData = <T>(key: string) => {
  const cache = useState<Record<string, { data: T; timestamp: number }>>(
    'cache',
    () => ({})
  )

  const getCachedData = async (
    fetchFn: () => Promise<T>,
    maxAge = 5 * 60 * 1000 // 5分
  ) => {
    const cached = cache.value[key]
    const now = Date.now()

    if (cached && now - cached.timestamp < maxAge) {
      return cached.data
    }

    const data = await fetchFn()
    cache.value[key] = { data, timestamp: now }
    return data
  }

  return {
    getCachedData
  }
}

ルート遷移のアニメーション

スムーズな画面遷移を実現するアニメーション実装:

vue
<!-- layouts/default.vue -->
<template>
  <div>
    <transition name="page" mode="out-in">
      <slot />
    </transition>
  </div>
</template>

<style lang="scss">
.page-enter-active,
.page-leave-active {
  transition: all 0.3s ease-out;
}

.page-enter-from {
  opacity: 0;
  transform: translateY(20px);
}

.page-leave-to {
  opacity: 0;
  transform: translateY(-20px);
}
</style>

これらの最適化により、以下のような効果が期待できます:

  1. 初期読み込み時間の短縮
  2. スムーズな画面遷移
  3. 効率的なリソース利用
  4. 優れたユーザー体験の提供

5. Nuxt3の作る実践的なディレクトリ構成

大規模なアプリケーションを効率的に開発・保守していくためには、適切なディレクトリ構成が不可欠です。Nuxt3の機能を最大限に活かしながら、チーム開発を円滑に進めるためのディレクトリ構成について解説していきます。

大規模アプリケーションでの構成例

実務で実績のある具体的なディレクトリ構成を見ていきましょう:

bash
my-nuxt-app/
├── components/
│   ├── common/           # 共通コンポーネント
│   │   ├── Button/
│   │   │   ├── index.vue
│   │   │   └── types.ts
│   │   └── Input/
│   ├── features/         # 機能単位のコンポーネント
│   │   ├── auth/
│   │   └── dashboard/
│   └── layout/          # レイアウト用コンポーネント
├── composables/
│   ├── auth/
│   │   ├── useAuth.ts
│   │   └── usePermission.ts
│   └── shared/
│       ├── useApi.ts
│       └── useToast.ts
├── pages/
│   ├── auth/
│   │   ├── login.vue
│   │   └── register.vue
│   └── dashboard/
│       ├── _middleware/
│       └── index.vue
├── utils/
│   ├── api/
│   └── validation/
└── types/
    ├── api.d.ts
    └── user.d.ts

機能ベースのルーティング設計

大規模アプリケーションでは、機能単位でのルーティング管理が重要です:

typescript
// pages/dashboard/_middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const { isAuthenticated, hasPermission } = useAuth()

  if (!isAuthenticated) {
    return navigateTo('/auth/login')
  }

  if (!hasPermission('view-dashboard')) {
    return navigateTo('/403')
  }
})

メンテナンス性を考慮したファイル配置

コードの再利用性と保守性を高めるためのベストプラクティス:

typescript
// components/features/dashboard/AnalyticsCard/types.ts
export interface AnalyticsData {
  title: string
  value: number
  trend: 'up' | 'down'
  percentage: number
}

// components/features/dashboard/AnalyticsCard/index.vue
<script setup lang="ts">
import type { AnalyticsData } from './types'

defineProps<{
  data: AnalyticsData
}>()
</script>

ルーティングのテスト戦略

効率的なテスト実行のための構成例:

typescript
// tests/pages/dashboard.spec.ts
import { describe, test, expect } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import DashboardPage from '~/pages/dashboard/index.vue'

describe('Dashboard Page', () => {
  test('認証済みユーザーがアクセスできる', async () => {
    const { auth } = useAuth()
    auth.value = { id: 1, role: 'admin' }

    const wrapper = await mountSuspended(DashboardPage)
    expect(wrapper.html()).toContain('Dashboard')
  })

  test('未認証ユーザーはリダイレクトされる', async () => {
    const { auth } = useAuth()
    auth.value = null

    // ミドルウェアのテスト
    const middleware = await import('~/pages/dashboard/_middleware/auth')
    const result = await middleware.default()
    
    expect(result?.path).toBe('/auth/login')
  })
})

このような構成を採用することで、以下のような利点が得られます:

  1. コードの見通しの良さ
  2. チーム開発での作業効率向上
  3. 機能単位での開発・テストの容易さ
  4. 将来の機能追加への対応のしやすさ