Next.js redirectを使った業務パターン実装ガイド|認証・権限・動的ルーティング対応

React / Next.js

Next.js redirectを使った業務パターン実装ガイド

Web開発において、ユーザーをある画面から別の画面へ遷移させることは日常茶飯事です。Next.jsのredirect機能は、単なるページ遷移ツールではなく、業務ロジックを組み込んだ賢い遷移制御を実現します。本記事では、実務で頻繁に遭遇するリダイレクトパターンと、その実装方法を詳しく解説します。

Next.js redirectの基本的な解説

Next.js 13以降で導入されたredirect関数は、サーバー側でリダイレクトを処理する機能です。従来のクライアント側でのリダイレクト(window.location.hrefなど)とは異なり、サーバー側で処理されるため、より安全で効率的です。

基本的な使い方は非常にシンプルです:

import { redirect } from 'next/navigation';

export default function Page() {
  redirect('/login');
}

しかし、実務ではここからがスタートです。認証状態の確認、ユーザー権限の検証、動的なURLの構築など、複雑な条件分岐を組み込む必要があります。

業務でのユースケース

実際のプロジェクトで遭遇するredirectのユースケースは以下の通りです。

1. 認証チェック後のリダイレクト

ユーザーがログインしていない状態でプロテクトされたページへアクセスした場合、ログインページへ遷移させる必要があります。これは最も一般的なユースケースです。

2. ユーザー権限による分岐

管理者と一般ユーザーで異なるダッシュボードを表示する場合など、権限に応じたリダイレクトが必要です。

3. ビジネスロジックに基づく動的リダイレクト

ユーザーの購買履歴や登録状況に応じて、異なるページへ誘導する場合があります。

4. URLの正規化

古いURLから新しいURLへのマイグレーション、あるいは複数の入口を1つのURLに統一する場合です。

実装コード:実務パターン集

パターン1:認証チェック付きダッシュボード

最も実装頻度の高いパターンです。セッションまたはJWTトークンを確認し、未認証ならログインページへ遷移させます。

// app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { cookies } from 'next/headers';

export default async function DashboardPage() {
  const cookieStore = cookies();
  const sessionToken = cookieStore.get('session_token')?.value;

  // トークンが存在しない、または無効な場合
  if (!sessionToken) {
    redirect('/login');
  }

  // トークンの有効性を確認(実装例)
  const isValid = await verifyToken(sessionToken);
  if (!isValid) {
    redirect('/login?expired=true');
  }

  return (
    

ダッシュボード

{/* ダッシュボード内容 */}
); } async function verifyToken(token: string): Promise { try { // APIエンドポイントでトークンを検証 const response = await fetch('http://localhost:3000/api/auth/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }), }); return response.ok; } catch { return false; } }

パターン2:ロールベースの権限チェック

ユーザーの権限レベルに応じて異なるページへリダイレクトします。管理者専用ページへのアクセス制御が典型的です。

// app/admin/page.tsx
import { redirect } from 'next/navigation';
import { getUserRole } from '@/lib/auth';

type UserRole = 'admin' | 'manager' | 'user' | null;

export default async function AdminPage() {
  const userRole: UserRole = await getUserRole();

  // ロールの確認
  if (!userRole) {
    redirect('/login');
  }

  if (userRole !== 'admin') {
    redirect('/unauthorized');
  }

  return (
    

管理画面

{/* 管理画面コンテンツ */}
); } async function getUserRole(): Promise { try { const response = await fetch('http://localhost:3000/api/user/role', { cache: 'no-store', }); if (!response.ok) { return null; } const data = await response.json(); return data.role; } catch { return null; } }

パターン3:動的URLのリダイレクト

URLパラメータを使用して、動的にリダイレクト先を決定します。ユーザーIDやプロジェクトIDなどが含まれる場合に活用します。

// app/project/[id]/page.tsx
import { redirect } from 'next/navigation';
import { getProjectStatus } from '@/lib/project';

interface ProjectPageProps {
  params: {
    id: string;
  };
}

export default async function ProjectPage({ params }: ProjectPageProps) {
  const projectId = params.id;

  // プロジェクトの状態を取得
  const project = await getProjectStatus(projectId);

  if (!project) {
    redirect('/projects?error=not-found');
  }

  // プロジェクトがアーカイブされている場合
  if (project.status === 'archived') {
    redirect(`/projects/${projectId}/archive`);
  }

  // ユーザーがプロジェクトへのアクセス権限がない場合
  if (!project.hasAccess) {
    redirect('/projects?error=forbidden');
  }

  return (
    

{project.name}

{/* プロジェクト詳細 */}
); } async function getProjectStatus(id: string) { try { const response = await fetch(`http://localhost:3000/api/projects/${id}`, { cache: 'no-store', }); if (!response.ok) { return null; } return await response.json(); } catch { return null; } }

パターン4:多段階認証フローのリダイレクト

ログイン後、メール認証やSMS認証など複数のステップを経る場合のリダイレクト制御です。

// app/verify-email/page.tsx
import { redirect } from 'next/navigation';
import { getAuthState } from '@/lib/auth-state';

interface AuthState {
  isLoggedIn: boolean;
  emailVerified: boolean;
  twoFactorEnabled: boolean;
  twoFactorVerified: boolean;
}

export default async function VerifyEmailPage() {
  const authState: AuthState = await getAuthState();

  // ログインしていない場合
  if (!authState.isLoggedIn) {
    redirect('/login');
  }

  // すでにメール認証済みの場合
  if (authState.emailVerified && authState.twoFactorEnabled) {
    if (!authState.twoFactorVerified) {
      redirect('/verify-2fa');
    } else {
      redirect('/dashboard');
    }
  }

  return (
    

メール認証

{/* メール認証フォーム */}
); } async function getAuthState(): Promise { // 実装省略(セッションから取得) return { isLoggedIn: false, emailVerified: false, twoFactorEnabled: false, twoFactorVerified: false, }; }

パターン5:条件付きURLの正規化

クエリパラメータに基づいて異なるURLへリダイレクトします。フィルター条件の最適化やユーザーの行動分析に活用されます。

// app/products/page.tsx
import { redirect } from 'next/navigation';
import { SearchParams } from 'next/dist/server/request/search-params';

interface ProductsPageProps {
  searchParams: SearchParams;
}

export default async function ProductsPage({ 
  searchParams 
}: ProductsPageProps) {
  const category = searchParams.get('category') as string | null;
  const sort = searchParams.get('sort') as string | null;

  // 不正なカテゴリーの場合
  if (category && !isValidCategory(category)) {
    redirect('/products?category=all');
  }

  // ソート順が無効な場合
  if (sort && !isValidSort(sort)) {
    redirect('/products');
  }

  // カテゴリーが指定されていない場合、デフォルトを設定
  if (!category) {
    redirect('/products?category=all');
  }

  return (
    

商品一覧

{/* 商品リスト */}
); } function isValidCategory(category: string): boolean { const validCategories = ['all', 'electronics', 'books', 'clothing']; return validCategories.includes(category); } function isValidSort(sort: string): boolean { const validSorts = ['newest', 'popular', 'price-low', 'price-high']; return validSorts.includes(sort); }

よくある応用パターン

ミドルウェアを使った集中的なリダイレクト管理

複数のページで認証チェックが必要な場合、ミドルウェアで一元管理すると保守性が向上します。

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

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  const sessionToken = request.cookies.get('session_token')?.value;

  // 保護されたページのリスト
  const protectedPaths = ['/dashboard', '/admin', '/profile', '/settings'];
  
  const isProtectedPage = protectedPaths.some(path => 
    pathname.startsWith(path)
  );

  if (isProtectedPage && !sessionToken) {
    // リダイレクト前のURLを保存して、ログイン後に戻せるようにする
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

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

カスタムフック:リダイレクト状態を管理

クライアント側でのリダイレクト判定が必要な場合、カスタムフックで状態を管理します。

// lib/useAuthRedirect.ts
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';

export function useAuthRedirect(requiredRole?: string) {
  const router = useRouter();
  const { user, isLoading } = useAuth();

  useEffect(() => {
    if (isLoading) return;

    if (!user) {
      router.push('/login');
      return;
    }

    if (requiredRole && user.role !== requiredRole) {
      router.push('/unauthorized');
      return;
    }
  }, [user, isLoading, requiredRole, router]);

  return { isAuthorized: !!user, isLoading };
}

リダイレクト先を決定するユーティリティ関数

複雑な条件分岐を関数として分離することで、テストとメンテナンスが容易になります。

// lib/redirect-logic.ts
interface UserContext {
  isAuthenticated: boolean;
  role: string | null;
  emailVerified: boolean;
  subscriptionStatus: 'active' | 'expired' | 'none';
}

export function determineRedirectPath(context: UserContext): string | null {
  // 未認証の場合
  if (!context.isAuthenticated) {
    return '/login';
  }

  // メール未認証の場合
  if (!context.emailVerified) {
    return '/verify-email';
  }

  // 管理者の場合
  if (context.role === 'admin') {
    return '/admin';
  }

  // サブスクリプション期限切れの場合
  if (context.subscriptionStatus === 'expired') {
    return '/subscription/renew';
  }

  // リダイレクト不要
  return null;
}

// 使用例
export async function checkAndRedirect(): Promise {
  const userContext: UserContext = await fetchUserContext();
  const redirectPath = determineRedirectPath(userContext);

  if (redirectPath) {
    redirect(redirectPath);
  }
}

async function fetchUserContext(): Promise {
  // 実装省略
  return {
    isAuthenticated: false,
    role: null,
    emailVerified: false,
    subscriptionStatus: 'none',
  };
}

注意点と落とし穴

1. リダイレクトループの防止

リダイレクト先のページでも同じ条件を満たす場合、無限ループに陥ります。必ず終了条件を確認してください。

// ❌ 悪い例:リダイレクトループが発生
// /page-a → /page-b → /page-a → ...

// ✅ 良い例:終了条件を明確にする
export default async function PageA() {
  const state = await getState();
  
  if (state.needsRedirect && state.currentPage !== 'page-b') {
    redirect('/page-b');
  }

  return 
Page A
; }

2. キャッシュ戦略の考慮

認証状態に基づくリダイレクトではキャッシュを無効化する必要があります。

// ✅ キャッシュを無効化する
const response = await fetch('http://localhost:3000/api/user', {
  cache: 'no-store', // 常に新しいデータを取得
});

// または

const response = await fetch('http://localhost:3000/api/user', {
  next: { revalidate: 0 }, // ISRを使用しない
});

3. エラーハンドリング

外部APIの呼び出しでエラーが発生した場合の処理を必ず実装してください。

export default async function ProtectedPage() {
  try {
    const isAuthorized = await checkAuthorization();
    
    if (!isAuthorized) {
      redirect('/login');
    }

    return 
Protected Content
; } catch (error) { console.error('Authorization check failed:', error); // エラーページへリダイレクト、またはフォールバック表示 redirect('/error'); } }

4. クライアント側でのリダイレクトの制限

redirect関数はサーバーコンポーネント内でのみ使用可能です。クライアントコンポーネントではuseRouterを使用します。

// ✅ サーバーコンポーネント内(OK)
export default async function ServerPage() {
  const isValid = await validate();
  if (!isValid) {
    redirect('/invalid');
  }
  return 
Valid
; } // ✅ クライアントコンポーネント内(useRouterを使用) 'use client'; import { useRouter } from 'next/navigation'; export default function ClientPage() { const router = useRouter(); const handleRedirect = () => { router.push('/destination'); }; return ; }

5. SEO への影響

リダイレクトは HTTP ステータスコードに影響します。検索エンジンのクローリング効率を損なわないよう注意が必要です。

// リダイレクト用の専用エンドポイントを検討
// app/api/redirect/route.ts
import { redirect } from 'next/navigation';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const destination = searchParams.get('to');

  if (!destination) {
    return new Response('Missing redirect destination', { status: 400 });
  }

  // 許可リストをチェック
  const allowedDestinations = ['/dashboard', '/profile'];
  if (!allowedDestinations.includes(destination)) {
    return new Response('Invalid redirect destination', { status: 403 });
  }

  redirect(destination);
}

パフォーマンス最適化のコツ

並列データ取得

複数の認証情報を並列で取得すると、処理時間を短縮できます。

export default async function DashboardPage() {
  // 並列実行で高速化
  const [userRole, subscription, emailStatus] = await Promise.all([
    getUserRole(),
    getSubscriptionStatus(),
    getEmailVerificationStatus(),
  ]);

  // 条件判定
  if (!userRole) {
    redirect('/login');
  }

  if (subscription.status === 'expired') {
    redirect('/subscription/renew');
  }

  if (!emailStatus.verified) {
    redirect('/verify-email');
  }

  return 
Dashboard
; }

テスト戦略

リダイレクト機能のテストは重要です。以下はJestを使用したテスト例です。

// __tests__/pages/dashboard.test.ts
import { redirect } from 'next/navigation';
import DashboardPage from '@/app/dashboard/page';

jest.mock('next/navigation');
jest.mock('@/lib/auth', () => ({
  verifyToken: jest.fn(),
}));

describe('DashboardPage', () => {
  it('未認証の場合、ログインページへリダイレクト', async () => {
    const mockRedirect = redirect as jest.MockedFunction;
    
    // テスト実行
    await DashboardPage();

    // 検証
    expect(mockRedirect).toHaveBeenCalledWith('/login');
  });

  it('無効なトークンの場合、エラーパラメータ付きでリダイレクト', async () => {
    const mockRedirect = redirect as jest.MockedFunction;
    
    // テスト実行
    await DashboardPage();

    // 検証
    expect(mockRedirect).toHaveBeenCalledWith('/login?expired=true');
  });
});

まとめ

Next.js の redirect 関数は、シンプルな API を備えながらも、実務で求められる複雑な要件に対応できる強力な機能です。認証チェック、権限管理、動的な URL 処理など、業務パターンごとに適切な実装方法を選択することが重要です。

本記事で紹介したパターンとベストプラクティスを参考にすることで、保守性が高く、セキュアなアプリケーションの構築が可能になります。特に以下の点を心がけてください:

  • リダイレクト条件を明確にし、ループを防止する
  • キャッシュ戦略を適切に設定する
  • エラーハンドリングを忘れない
  • クライアント/サーバーコンポーネントの使い分けを理解する
  • テストを充実させる

これらを実践することで、堅牢で高速なアプリケーションの実装が実現できます。

タイトルとURLをコピーしました