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

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 Boundary(
app/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 アプリケーション開発を支援しています。 設計レビュー・実装支援・チーム教育など、お気軽にご相談ください。
