← ブログ一覧

Next.js App Router の実装パターン:認証・データ取得・エラーハンドリング

Next.js App Router で迷いがちな認証フロー・データ取得・エラー処理の実装パターンを整理。Server Actions・middleware・error.tsx の使い分けを具体例とともに解説します。

#Next.js#TypeScript#Web#アーキテクチャ
Next.js App Router の実装パターン:認証・データ取得・エラーハンドリング

Next.js App Router では、従来の Pages Router と異なる設計思想により「認証をどこで行うか」「データをどう取得するか」「エラーをどう扱うか」の判断に迷うケースが多く見られます。本記事では、受託開発・自社開発の現場で即使える実装パターンを、優先度別に整理して解説します。


1. 認証フローの実装パターン

1-1. 認証方式の選定基準

Next.js App Router で採用できる主な認証方式と、それぞれの適用シーンを整理します。

| 認証方式 | 適用シーン | セッション管理 | 実装難易度 | |---------|-----------|--------------|----------| | NextAuth.js (Auth.js) | 多様な OAuth プロバイダ対応が必要 | Cookie ベース | 中 | | Supabase Auth | バックエンドも Supabase で統一 | JWT + Cookie | 低 | | Clerk | 認証 UI・MFA を素早く導入 | 独自セッション | 低 | | 自前 JWT | 既存 API との連携・細かい制御 | JWT (localStorage/Cookie) | 高 |

推奨パターン:

  • スタートアップ MVP: Supabase Auth または Clerk(実装速度優先)
  • 受託案件(既存システム連携): 自前 JWT または NextAuth.js(柔軟性優先)
  • エンタープライズ: NextAuth.js + 社内 IDP 連携(拡張性優先)

1-2. Middleware による認証ガード

App Router では middleware.ts で全リクエストを横断的に処理できます。

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

const publicPaths = ['/login', '/signup', '/api/auth']
const protectedPaths = ['/dashboard', '/settings', '/api/protected']

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  const token = request.cookies.get('session')?.value

  // 公開パスはスルー
  if (publicPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.next()
  }

  // 保護されたパスで未認証ならログインへ
  if (protectedPaths.some(path => pathname.startsWith(path)) && !token) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    url.searchParams.set('redirect', pathname)
    return NextResponse.redirect(url)
  }

  // 認証済みユーザーがログインページにアクセスしたらダッシュボードへ
  if (pathname === '/login' && token) {
    return NextResponse.redirect(new URL('/dashboard', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
}

実務 Tips:

  • matcher で静的ファイルを除外しないとパフォーマンス低下
  • リダイレクトループを防ぐため、公開パス・保護パスを明示的に定義
  • searchParams で元の URL を保持し、ログイン後に戻す UX を実装

1-3. Server Component での認証状態取得

// app/dashboard/page.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { verifySession } from '@/lib/auth'

export default async function DashboardPage() {
  const cookieStore = await cookies()
  const token = cookieStore.get('session')?.value

  if (!token) {
    redirect('/login')
  }

  const user = await verifySession(token)
  if (!user) {
    redirect('/login')
  }

  return (
    <div>
      <h1>ようこそ、{user.name}さん</h1>
      {/* ... */}
    </div>
  )
}

注意点:

  • cookies() は async 関数になったため必ず await が必要(Next.js 15+)
  • redirect() は throw するため、以降のコードは実行されない
  • Server Component なので、初回レンダリング時にサーバー側で認証チェック完了

2. データ取得の実装パターン

2-1. データ取得の基本方針

| 手法 | 実行環境 | キャッシュ | 用途 | |-----|---------|-----------|-----| | Server Component で fetch | サーバー | デフォルト有効 | 初期表示データ | | Server Actions | サーバー | 無効 | フォーム送信・Mutation | | Route Handlers | サーバー | カスタマイズ可 | 外部 API 連携・Webhook | | Client Component で fetch | ブラウザ | SWR/React Query 推奨 | 動的更新・インタラクション |

2-2. Server Component でのデータ取得(推奨パターン)

// app/posts/page.tsx
import { Post } from '@/types'

async function getPosts(): Promise<Post[]> {
  const res = await fetch('https://api.example.com/posts', {
    // デフォルトで force-cache(静的生成相当)
    // 再検証が必要な場合:
    next: { revalidate: 60 }, // 60秒ごとに再検証(ISR)
    // または
    // cache: 'no-store', // 毎回最新データ取得(SSR)
  })

  if (!res.ok) {
    throw new Error('Failed to fetch posts')
  }

  return res.json()
}

export default async function PostsPage() {
  const posts = await getPosts()

  return (
    <div>
      <h1>投稿一覧</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

キャッシュ戦略の選び方:

  • 静的サイト(ブログ等): デフォルトの force-cache
  • 定期更新コンテンツ: revalidate: 60(ISR)
  • ユーザー固有データ: cache: 'no-store'(SSR)
  • リアルタイム性重視: Client Component で SWR

2-3. Server Actions によるフォーム処理

// app/posts/create/page.tsx
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

async function createPost(formData: FormData) {
  'use server'

  const title = formData.get('title') as string
  const content = formData.get('content') as string

  // バリデーション
  if (!title || title.length < 3) {
    throw new Error('タイトルは3文字以上必要です')
  }

  // データベース登録(例:Prisma)
  await prisma.post.create({
    data: { title, content },
  })

  // 投稿一覧のキャッシュを無効化
  revalidatePath('/posts')

  // 作成後は一覧ページへ
  redirect('/posts')
}

export default function CreatePostPage() {
  return (
    <form action={createPost}>
      <input type="text" name="title" required />
      <textarea name="content" />
      <button type="submit">投稿</button>
    </form>
  )
}

実務 Tips:

  • revalidatePath() で関連ページのキャッシュを明示的に無効化
  • エラーは error.tsx でキャッチ可能(後述)
  • 複雑なバリデーションは Zod 等のスキーマ検証ライブラリを併用

2-4. 並列データ取得によるパフォーマンス最適化

// app/dashboard/page.tsx
export default async function DashboardPage() {
  // 並列実行で待ち時間を最小化
  const [user, stats, notifications] = await Promise.all([
    getUser(),
    getStats(),
    getNotifications(),
  ])

  return (
    <div>
      <UserProfile user={user} />
      <StatsWidget stats={stats} />
      <NotificationList notifications={notifications} />
    </div>
  )
}

注意点:

  • 直列で await を3回書くと合計待ち時間が3倍になる
  • Promise.all() で並列化し、最も遅い処理の時間まで短縮
  • 依存関係がある場合(例:ユーザー ID 取得→詳細取得)は直列が必須

3. エラーハンドリングの実装パターン

3-1. error.tsx による Error Boundary

App Router では、各階層に error.tsx を配置することで、その配下のエラーを自動キャッチできます。

// app/posts/error.tsx
'use client' // Error Boundary は必ず Client Component

import { useEffect } from 'react'

interface ErrorProps {
  error: Error & { digest?: string }
  reset: () => void
}

export default function PostsError({ error, reset }: ErrorProps) {
  useEffect(() => {
    // エラーログ送信(例:Sentry)
    console.error('Posts error:', error)
  }, [error])

  return (
    <div className="error-container">
      <h2>投稿の読み込みに失敗しました</h2>
      <p>{error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  )
}

階層ごとのエラーハンドリング設計:

app/
├── error.tsx          # グローバルエラー(フォールバック)
├── dashboard/
│   ├── error.tsx      # ダッシュボード固有エラー
│   └── settings/
│       └── error.tsx  # 設定ページ固有エラー

3-2. not-found.tsx による 404 ハンドリング

// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation'

async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`)
  if (!res.ok) return null
  return res.json()
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id)

  if (!post) {
    notFound() // app/posts/[id]/not-found.tsx を表示
  }

  return <article>{post.content}</article>
}
// app/posts/[id]/not-found.tsx
export default function PostNotFound() {
  return (
    <div>
      <h2>投稿が見つかりません</h2>
      <a href="/posts">一覧に戻る</a>
    </div>
  )
}

3-3. エラー種別ごとの処理分岐

// lib/errors.ts
export class UnauthorizedError extends Error {
  constructor(message = '認証が必要です') {
    super(message)
    this.name = 'UnauthorizedError'
  }
}

export class ForbiddenError extends Error {
  constructor(message = 'アクセス権限がありません') {
    super(message)
    this.name = 'ForbiddenError'
  }
}

export class ValidationError extends Error {
  constructor(public fields: Record<string, string>) {
    super('バリデーションエラー')
    this.name = 'ValidationError'
  }
}
// app/error.tsx
'use client'

import { UnauthorizedError, ForbiddenError, ValidationError } from '@/lib/errors'
import { useRouter } from 'next/navigation'

export default function GlobalError({ error, reset }: ErrorProps) {
  const router = useRouter()

  if (error instanceof UnauthorizedError) {
    return (
      <div>
        <h2>ログインが必要です</h2>
        <button onClick={() => router.push('/login')}>ログイン</button>
      </div>
    )
  }

  if (error instanceof ForbiddenError) {
    return (
      <div>
        <h2>アクセス権限がありません</h2>
        <button onClick={() => router.back()}>戻る</button>
      </div>
    )
  }

  if (error instanceof ValidationError) {
    return (
      <div>
        <h2>入力内容を確認してください</h2>
        <ul>
          {Object.entries(error.fields).map(([field, message]) => (
            <li key={field}>{field}: {message}</li>
          ))}
        </ul>
        <button onClick={reset}>修正する</button>
      </div>
    )
  }

  // 予期しないエラー
  return (
    <div>
      <h2>予期しないエラーが発生しました</h2>
      <button onClick={reset}>再試行</button>
    </div>
  )
}

4. Server Actions のエラーハンドリング

4-1. useFormState による状態管理

// app/posts/create/page.tsx
'use client'

import { useFormState } from 'react-dom'
import { createPost } from './actions'

const initialState = {
  message: '',
  errors: {},
}

export default function CreatePostForm() {
  const [state, formAction] = useFormState(createPost, initialState)

  return (
    <form action={formAction}>
      <div>
        <input type="text" name="title" />
        {state.errors?.title && (
          <p className="error">{state.errors.title}</p>
        )}
      </div>
      <div>
        <textarea name="content" />
        {state.errors?.content && (
          <p className="error">{state.errors.content}</p>
        )}
      </div>
      {state.message && <p>{state.message}</p>}
      <button type="submit">投稿</button>
    </form>
  )
}
// app/posts/create/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const postSchema = z.object({
  title: z.string().min(3, 'タイトルは3文字以上必要です'),
  content: z.string().min(10, '本文は10文字以上必要です'),
})

export async function createPost(prevState: any, formData: FormData) {
  const validatedFields = postSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: '入力内容を確認してください',
    }
  }

  try {
    await prisma.post.create({
      data: validatedFields.data,
    })

    revalidatePath('/posts')

    return {
      message: '投稿を作成しました',
      errors: {},
    }
  } catch (error) {
    return {
      message: 'データベースエラーが発生しました',
      errors: {},
    }
  }
}

実務 Tips:

  • useFormState で Server Action の結果を状態として扱える
  • Zod で型安全なバリデーション + エラーメッセージ生成
  • 成功時も revalidatePath でキャッシュ更新を忘れずに

5. Route Handlers でのエラーレスポンス設計

5-1. 標準的なエラーレスポンス形式

// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(3),
  content: z.string(),
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()

    // バリデーション
    const validatedData = postSchema.parse(body)

    // データベース登録
    const post = await prisma.post.create({
      data: validatedData,
    })

    return NextResponse.json(
      { success: true, data: post },
      { status: 201 }
    )
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json(
        {
          success: false,
          error: 'Validation failed',
          details: error.errors,
        },
        { status: 400 }
      )
    }

    console.error('API error:', error)

    return NextResponse.json(
      {
        success: false,
        error: 'Internal server error',
      },
      { status: 500 }
    )
  }
}

5-2. エラーレスポンスの統一フォーマット

// lib/api-response.ts
import { NextResponse } from 'next/server'

export function successResponse<T>(data: T, status = 200) {
  return NextResponse.json(
    { success: true, data },
    { status }
  )
}

export function errorResponse(
  message: string,
  status = 500,
  details?: any
) {
  return NextResponse.json(
    { success: false, error: message, details },
    { status }
  )
}

export function validationErrorResponse(errors: any) {
  return errorResponse('Validation failed', 400, errors)
}

export function unauthorizedResponse() {
  return errorResponse('Unauthorized', 401)
}

export function forbiddenResponse() {
  return errorResponse('Forbidden', 403)
}

export function notFoundResponse(resource = 'Resource') {
  return errorResponse(`${resource} not found`, 404)
}

使用例:

// app/api/posts/[id]/route.ts
import { notFoundResponse, successResponse } from '@/lib/api-response'

export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  const post = await prisma.post.findUnique({
    where: { id: params.id },
  })

  if (!post) {
    return notFoundResponse('Post')
  }

  return successResponse(post)
}

6. 実装パターン選定チェックリスト

6-1. 認証実装の判断フロー

既存の認証基盤がある?
├─ YES → 自前 JWT または NextAuth.js(カスタムプロバイダ)
└─ NO
    ├─ バックエンドも新規構築?
    │   ├─ YES → Supabase Auth(フルスタック統一)
    │   └─ NO → NextAuth.js(汎用性重視)
    └─ 素早く MVP を立ち上げたい?
        └─ YES → Clerk(UI・MFA 込み)

6-2. データ取得手法の選定基準

| 要件 | 推奨手法 | 理由 | |-----|---------|-----| | 初期表示データ(静的) | Server Component + force-cache | ビルド時生成でパフォーマンス最高 | | 初期表示データ(定期更新) | Server Component + revalidate | ISR で再生成コスト削減 | | 初期表示データ(動的) | Server Component + no-store | 常に最新データ | | フォーム送信 | Server Actions | 型安全・Streaming 対応 | | リアルタイム更新 | Client Component + SWR/RQ | 自動再検証・楽観的更新 | | 外部 API 連携 | Route Handlers | CORS・認証・レート制限を集約 |

6-3. エラーハンドリングの実装チェックリスト

  • [ ] グローバル Error Boundaryapp/error.tsx)を実装
  • [ ] 各セクションごとの Error Boundary を必要に応じて配置
  • [ ] 404 ページapp/not-found.tsx)をカスタマイズ
  • [ ] Server Actions のバリデーションエラーuseFormState で表示
  • [ ] Route Handlers のエラーレスポンス を統一フォーマットで返却
  • [ ] エラーログ送信(Sentry 等)を Error Boundary 内で実装
  • [ ] ユーザー向けエラーメッセージ開発者向けログ を分離

7. よくある実装ミスと対処法

7-1. Middleware でのリダイレクトループ

NG パターン:

export function middleware(request: NextRequest) {
  const token = request.cookies.get('session')?.value

  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

問題点: /login 自体も Middleware を通るため無限リダイレクト

OK パターン:

const publicPaths = ['/login', '/signup']

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  if (publicPaths.some(path => pathname.startsWith(path))) {
    return NextResponse.next()
  }

  const token = request.cookies.get('session')?.value
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

7-2. Server Component でのクライアント依存処理

NG パターン:

export default async function Page() {
  const data = await fetch(...)

  // Server Component で localStorage は使えない
  localStorage.setItem('data', JSON.stringify(data))

  return <div>...</div>
}

OK パターン:

// app/page.tsx (Server Component)
export default async function Page() {
  const data = await fetch(...)

  return <ClientWrapper initialData={data} />
}

// components/client-wrapper.tsx (Client Component)
'use client'

export function ClientWrapper({ initialData }) {
  useEffect(() => {
    localStorage.setItem('data', JSON.stringify(initialData))
  }, [initialData])

  return <div>...</div>
}

7-3. 直列 await によるパフォーマンス劣化

NG パターン(合計 900ms):

export default async function Page() {
  const user = await getUser()       // 300ms
  const posts = await getPosts()     // 300ms
  const comments = await getComments() // 300ms

  return <Dashboard user={user} posts={posts} comments={comments} />
}

OK パターン(最大 300ms):

export default async function Page() {
  const [user, posts, comments] = await Promise.all([
    getUser(),
    getPosts(),
    getComments(),
  ])

  return <Dashboard user={user} posts={posts} comments={comments} />
}

8. まとめ

Next.js App Router の認証・データ取得・エラーハンドリングは、従来の Pages Router とは異なる実装パターンが求められます。本記事で紹介した内容を振り返ります。

認証実装のポイント

  • Middleware で全体的なガードを行い、リダイレクトループに注意
  • Server Component で個別チェックを実装し、未認証時は即座に redirect
  • 認証サービス選定は、既存システムとの連携・実装速度・機能要件で判断

データ取得の設計指針

  • 初期表示は Server Component、リアルタイム性が必要なら Client Component
  • 並列データ取得 で待ち時間を最小化
  • キャッシュ戦略 をコンテンツの更新頻度に応じて選択

エラーハンドリングの実装

  • error.tsx で階層的に Error Boundary を配置
  • Server Actions では useFormState でバリデーションエラーを表示
  • Route Handlers のレスポンス形式 を統一し、型安全に

これらのパターンを組み合わせることで、保守性・パフォーマンス・UX のバランスが取れた Next.js アプリケーションを構築できます。


Yureate では、Next.js を活用した Web アプリケーション開発を支援しています。 設計レビュー・実装支援・チーム教育など、お気軽にご相談ください。

お問い合わせはこちら

この内容について相談する他の記事を見る