Next.js ミドルウェアの実践的な使い方|認証・ロギング・リダイレクト実装ガイド

React / Next.js

Next.js ミドルウェアの実践的な使い方|認証・ロギング・リダイレクト実装ガイド

Next.js 13以降で導入されたミドルウェア機能は、リクエスト/レスポンスの処理をエッジレベルで実行できる強力な機能です。本記事では、実務で実際に使用されるパターンを中心に、実装方法と注意点を解説します。

ミドルウェアの基本概念

Next.jsのミドルウェアは、リクエストが処理される前に実行される中間処理層です。Vercelのエッジネットワークで動作するため、レスポンスタイムが非常に高速です。

主な特徴:

  • エッジで実行されるため遅延が少ない
  • リクエスト/レスポンスの改変が可能
  • 認証・認可・ロギングに最適
  • サーバーレス環境での実行

ミドルウェアはmiddleware.ts(またはmiddleware.js)をプロジェクトルートに配置して実装します。

実務で使用される主なユースケース

1. 認証状態の確認とリダイレクト

ユーザーのログイン状態を確認し、未ログイン状態でプロテクトされたページにアクセスした場合にログインページへリダイレクトするパターンです。これは実務で最も頻繁に使用されます。

2. リクエストのロギングと監視

すべてのリクエストを記録し、異常なアクセスパターンを検出します。レスポンスヘッダーに処理時間を追加することも実装に含まれます。

3. 国別リダイレクト(多言語対応)

ユーザーのIPアドレスやヘッダー情報から国を判定し、適切な言語版へ自動リダイレクトします。

4. APIレートリミット

クライアントのIPアドレスごとにリクエスト数を制限します。

5. セキュリティヘッダーの追加

すべてのレスポンスにセキュリティ関連のカスタムヘッダーを追加します。

実装コード:認証ミドルウェアの完全版

まず、実務で実際に使用される認証ミドルウェアの実装例を示します。

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

// 保護対象のパスを定義
const PROTECTED_PATHS = ['/dashboard', '/admin', '/profile', '/settings'];
const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];
const API_PROTECTED_PATHS = ['/api/user', '/api/admin'];

// トークンの検証(JWTの場合)
function verifyToken(token: string): boolean {
  try {
    // 実務では jwt-decode などのライブラリを使用
    const parts = token.split('.');
    if (parts.length !== 3) return false;
    
    // JWTの署名検証(簡略版)
    const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
    
    // トークンの有効期限を確認
    if (payload.exp) {
      const now = Math.floor(Date.now() / 1000);
      if (payload.exp < now) return false;
    }
    
    return true;
  } catch (error) {
    return false;
  }
}

// クッキーからトークンを取得
function getTokenFromRequest(request: NextRequest): string | null {
  const token = request.cookies.get('authToken')?.value;
  
  // クッキーがない場合、Authorizationヘッダーから取得
  if (!token) {
    const authHeader = request.headers.get('authorization');
    if (authHeader?.startsWith('Bearer ')) {
      return authHeader.slice(7);
    }
  }
  
  return token || null;
}

export const middleware: NextMiddleware = async (request: NextRequest) => {
  const { pathname } = request.nextUrl;
  
  // ログ記録(実務では外部サービスに送信)
  console.log(`[${new Date().toISOString()}] ${request.method} ${pathname}`);
  
  // 公開ページへのアクセスは許可
  if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
    const response = NextResponse.next();
    response.headers.set('X-Content-Type-Options', 'nosniff');
    return response;
  }
  
  // トークンを取得
  const token = getTokenFromRequest(request);
  
  // 保護されたページへのアクセス確認
  if (PROTECTED_PATHS.some(path => pathname.startsWith(path))) {
    if (!token || !verifyToken(token)) {
      // ログインページへリダイレクト(クエリパラメータで遷移元を保持)
      const loginUrl = new URL('/login', request.url);
      loginUrl.searchParams.set('from', pathname);
      return NextResponse.redirect(loginUrl);
    }
  }
  
  // API保護パスへのアクセス確認
  if (API_PROTECTED_PATHS.some(path => pathname.startsWith(path))) {
    if (!token || !verifyToken(token)) {
      return NextResponse.json(
        { error: 'Unauthorized' },
        { status: 401 }
      );
    }
  }
  
  // レスポンスにセキュリティヘッダーを追加
  const response = NextResponse.next();
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-XSS-Protection', '1; mode=block');
  
  return response;
};

// ミドルウェアが実行されるパスを指定
export const config = {
  matcher: [
    // 以下のパスで実行
    '/dashboard/:path*',
    '/admin/:path*',
    '/profile/:path*',
    '/settings/:path*',
    '/api/:path*',
    // 静的ファイルと _next は除外
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

実装コード:ロギングと監視機能付きミドルウェア

リクエストの処理時間を測定し、ログを記録する実装例です。

// middleware.ts(ロギング機能付き)
import { NextRequest, NextResponse } from 'next/server';

interface RequestLog {
  timestamp: string;
  method: string;
  path: string;
  statusCode: number;
  duration: number;
  userAgent?: string;
  ip?: string;
}

// ログをファイルまたはDBに保存する関数
async function logRequest(log: RequestLog): Promise {
  try {
    // 実務では外部のロギングサービスを使用(例:Datadog, CloudWatch)
    if (process.env.NODE_ENV === 'production') {
      // 例:CloudWatchへのログ送信
      await fetch(process.env.LOG_ENDPOINT || '', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(log),
      }).catch(() => {}); // エラーは無視(ログ送信の失敗がリクエストを止めないように)
    } else {
      // 開発環境ではコンソールに出力
      console.log(JSON.stringify(log, null, 2));
    }
  } catch (error) {
    console.error('Failed to log request:', error);
  }
}

// IPアドレスを取得
function getClientIP(request: NextRequest): string {
  const forwarded = request.headers.get('x-forwarded-for');
  if (forwarded) {
    return forwarded.split(',')[0].trim();
  }
  return request.headers.get('x-real-ip') || 'unknown';
}

export async function middleware(request: NextRequest) {
  const startTime = performance.now();
  const { pathname, search } = request.nextUrl;
  
  try {
    // リクエスト処理
    let response = NextResponse.next();
    
    // 処理時間を計算
    const duration = Math.round(performance.now() - startTime);
    
    // ログを記録
    const log: RequestLog = {
      timestamp: new Date().toISOString(),
      method: request.method,
      path: pathname + search,
      statusCode: response.status,
      duration,
      userAgent: request.headers.get('user-agent') || undefined,
      ip: getClientIP(request),
    };
    
    // 非同期でログを送信(リクエストをブロックしない)
    logRequest(log).catch(console.error);
    
    // 処理時間がしきい値を超えた場合は警告
    if (duration > 1000) {
      console.warn(`Slow request detected: ${pathname} took ${duration}ms`);
    }
    
    return response;
  } catch (error) {
    console.error('Middleware error:', error);
    return NextResponse.next();
  }
}

export const config = {
  matcher: '/:path*',
};

実装コード:複数の機能を組み合わせた高度なミドルウェア

実務では複数の機能を組み合わせることが多いです。以下は認証、レート制限、国別リダイレクトを組み合わせた例です。

// middleware.ts(複合機能版)
import { NextRequest, NextResponse } from 'next/server';

// レート制限の設定(IPごとに1分間に100リクエストまで)
const RATE_LIMIT_CONFIG = {
  maxRequests: 100,
  windowMs: 60 * 1000, // 1分
};

// メモリ内のレート制限ストア(本番環境ではRedisを使用)
const rateLimitStore = new Map();

function checkRateLimit(ip: string): boolean {
  const now = Date.now();
  const record = rateLimitStore.get(ip);
  
  if (!record || now > record.resetTime) {
    rateLimitStore.set(ip, {
      count: 1,
      resetTime: now + RATE_LIMIT_CONFIG.windowMs,
    });
    return true;
  }
  
  if (record.count < RATE_LIMIT_CONFIG.maxRequests) {
    record.count++;
    return true;
  }
  
  return false;
}

function getClientIP(request: NextRequest): string {
  const forwarded = request.headers.get('x-forwarded-for');
  return forwarded ? forwarded.split(',')[0].trim() : 'unknown';
}

function getCountryFromRequest(request: NextRequest): string | null {
  // Vercelはx-vercel-ip-countryヘッダーを自動的に設定
  return request.headers.get('x-vercel-ip-country') || 
         request.geo?.country || 
         null;
}

export async function middleware(request: NextRequest) {
  const { pathname, searchParams } = request.nextUrl;
  const clientIP = getClientIP(request);
  
  // レート制限チェック
  if (!checkRateLimit(clientIP)) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }
  
  // 国別リダイレクト(多言語対応)
  const country = getCountryFromRequest(request);
  const currentLocale = pathname.split('/')[1];
  
  // 設定されている言語リスト
  const supportedLocales = ['ja', 'en', 'zh'];
  const localeMap: Record = {
    'JP': 'ja',
    'US': 'en',
    'GB': 'en',
    'CN': 'zh',
    'TW': 'zh',
  };
  
  // すでに言語が指定されている場合はスキップ
  if (!supportedLocales.includes(currentLocale)) {
    const preferredLocale = country ? (localeMap[country] || 'en') : 'en';
    
    // クッキーで言語設定を確認
    const savedLocale = request.cookies.get('NEXT_LOCALE')?.value;
    const targetLocale = savedLocale || preferredLocale;
    
    if (targetLocale !== 'en') { // デフォルトは英語
      const newUrl = new URL(`/${targetLocale}${pathname}`, request.url);
      newUrl.search = request.nextUrl.search;
      return NextResponse.redirect(newUrl);
    }
  }
  
  // 認証チェック(管理画面の場合)
  if (pathname.startsWith('/admin') || pathname.startsWith('/ja/admin')) {
    const token = request.cookies.get('authToken')?.value;
    
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }
  
  // レスポンスを作成
  const response = NextResponse.next();
  
  // セキュリティヘッダーを追加
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('X-Frame-Options', 'SAMEORIGIN');
  response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  
  // レート制限情報をヘッダーに追加
  const record = rateLimitStore.get(clientIP);
  if (record) {
    response.headers.set(
      'X-RateLimit-Remaining',
      String(RATE_LIMIT_CONFIG.maxRequests - record.count)
    );
  }
  
  return response;
}

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

よくある応用パターン

パターン1:APIのバージョニング

レガシーAPIへのリクエストを新しいバージョンにリダイレクトします。

// /api/v1/users へのリクエストを /api/v2/users にリダイレクト
if (pathname.startsWith('/api/v1/')) {
  const newPath = pathname.replace('/api/v1/', '/api/v2/');
  return NextResponse.redirect(new URL(newPath, request.url));
}

パターン2:メンテナンスページの表示

特定の時間帯やフラグに基づいてメンテナンスページを表示します。

// 環境変数でメンテナンスモードを制御
if (process.env.MAINTENANCE_MODE === 'true') {
  // ホワイトリスト内のIPは通常通りアクセス可能
  const adminIPs = (process.env.ADMIN_IPS || '').split(',');
  const clientIP = getClientIP(request);
  
  if (!adminIPs.includes(clientIP)) {
    return NextResponse.rewrite(new URL('/maintenance', request.url));
  }
}

パターン3:カスタムヘッダーに基づく処理

クライアントから送信されたカスタムヘッダーに基づいて異なる処理を実行します。

const apiVersion = request.headers.get('x-api-version') || 'v1';
const isPreview = request.headers.get('x-preview-mode') === 'true';

if (isPreview) {
  // プレビューモード用の処理
  request.headers.set('x-preview-mode', 'true');
}

const response = NextResponse.next();
response.headers.set('X-API-Version', apiVersion);

パターン4:キャッシュ制御

ページのタイプに応じてキャッシュヘッダーを動的に設定します。

// 静的コンテンツのキャッシュ設定
if (pathname.match(/\\.(jpg|jpeg|png|gif|webp|svg|pdf)$/)) {
  const response = NextResponse.next();
  response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
  return response;
}

// APIレスポンスはキャッシュしない
if (pathname.startsWith('/api/')) {
  const response = NextResponse.next();
  response.headers.set('Cache-Control', 'no-store, must-revalidate');
  return response;
}

ミドルウェアの注意点と落とし穴

1. パフォーマンスへの影響

ミドルウェアはすべてのリクエストで実行されるため、処理は可能な限り軽量に保つ必要があります。重い計算や外部APIへの同期呼び出しは避けてください。

// ❌ 避けるべき:同期的な重い処理
const result = expensiveComputation(request);

// ✅ 推奨:非同期で実行するか、キャッシュを利用
const cached = await cache.get(key);
if (!cached) {
  // 非同期で処理を進行させ、リクエストはブロックしない
}

2. ミドルウェアの実行順序

複数のミドルウェアが定義されている場合、実行順序は予測可能にする必要があります。単一のmiddleware.ts内で順序を制御しましょう。

3. 静的ファイルの除外

config.matcherで適切に除外設定を行わないと、画像やCSSなどの静的ファイルもミドルウェアを通過し、パフォーマンスが低下します。

// ✅ 推奨:静的ファイルを確実に除外
export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)',
  ],
};

4. トークンの検証費用

JWTの検証を毎回実行すると負荷が増加します。キャッシュの活用やトークンの有効期限の延長を検討してください。

5. ミドルウェアとサーバーコンポーネントの使い分け

ミドルウェアは認証・リダイレクト・リクエスト改変に、サーバーコンポーネントはデータ取得に使い分けることが重要です。

// ❌ 避けるべき:ミドルウェアでのDB直接アクセス
const user = await db.users.findUnique({ id });

// ✅ 推奨:トークン検証のみ
const isValid = verifyToken(token);

6. エラーハンドリング

ミドルウェアでエラーが発生した場合、ユーザーに影響を与えないよう適切に処理する必要があります。

try {
  // 処理
} catch (error) {
  // エラーをログに記録
  console.error('Middleware error:', error);
  
  // ユーザーには影響を与えない(通常通り処理を続行)
  return NextResponse.next();
}

実務での導入例

実際のプロジェクトでは、以下のような構成が一般的です。

// lib/auth.ts(認証ロジックの分離)
export function verifyToken(token: string): { valid: boolean; userId?: string } {
  try {
    // JWTライブラリを使用
    const decoded = jwt.verify(token, process.env.JWT_SECRET!);
    return { valid: true, userId: decoded.sub };
  } catch {
    return { valid: false };
  }
}

// lib/rateLimit.ts(レート制限の分離)
class RateLimiter {
  private store = new Map();
  
  check(key: string, limit: number, window: number): boolean {
    const now = Date.now();
    const record = this.store.get(key);
    
    if (!record || now > record.resetTime) {
      this.store.set(key, { count: 1, resetTime: now + window });
      return true;
    }
    
    return record.count++ < limit;
  }
  
  cleanup() {
    const now = Date.now();
    for (const [key, record] of this.store.entries()) {
      if (now > record.resetTime) {
        this.store.delete(key);
      }
    }
  }
}

export const limiter = new RateLimiter();

このように関数を分離することで、テストも容易になり、ミドルウェア自体もシンプルに保てます。

まとめ

Next.jsのミドルウェアは、認証・ロギング・リダイレクトなど、様々な実務要件に対応できる強力な機能です。要点をまとめると:

  • シンプルに保つ:重い処理は避け、必要な場合は非同期で実行
  • 適切に除外する:静的ファイルやヘルスチェックエンドポイントは除外
  • 関心の分離:認証・ロギング・レート制限など、機能ごとに関数を分離
  • エラーハンドリング:ミドルウェアのエラーがユーザー体験を損なわないよう注意
  • 監視とログ:本番環境では外部のログサービスを活用
  • テスト:ミドルウェアは独立してユニットテストを実施

実務では、プロジェクトの要件に応じてこれらのパターンを組み合わせることになります。最初はシンプルな実装から始めて、必要に応じて機能を追加していく方法をお勧めします。

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