Next.js Middlewareの業務実装パターン|認証・ログ・リダイレクト実例集

React / Next.js

Next.js Middlewareの業務実装パターン|認証・ログ・リダイレクト実例集

Next.js Middlewareとは

Next.js 12以降で導入されたMiddlewareは、リクエストがサーバーに到達する前に処理を挟み込める機能です。従来のページレベルの認証チェックよりも早い段階で制御でき、業務では認証・ロール判定・アクセス制御・ログ記録などで活躍します。

Middleware自体はEdge Runtimeで実行されるため、冷起動が少なく、レスポンスが高速です。これが実務で重要な理由は、毎回のリクエストで実行されるため、パフォーマンスが直接的にユーザー体験に影響するからです。

業務でのユースケース

筆者が携わったプロジェクトでは、以下のような要件がMiddlewareで解決できました。

  • 認証ガード:ログインしていないユーザーが保護されたページにアクセスした時の自動リダイレクト
  • ロールベースアクセス制御(RBAC):管理者ページへの権限チェック
  • テナント分離:SaaSサービスで顧客ごとのデータアクセス制御
  • 地域制限:特定国からのアクセスブロック
  • レート制限:APIエンドポイントへの過度なアクセス防止
  • リクエストログ:全リクエストのトラッキング

特に認証関連の処理は、Middlewareで一元管理することで、各ページでの重複チェックを避けられます。

実装パターン①:認証ガード

最も一般的なパターンが、ログイン状態の確認とリダイレクトです。JWTトークンをCookieに保存している想定で実装します。

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

export function middleware(request: NextRequestType) {
  const pathname = request.nextUrl.pathname;
  
  // 認証が不要なパス
  const publicPaths = ['/login', '/signup', '/api/auth/login', '/', '/about'];
  const isPublicPath = publicPaths.some(path => pathname.startsWith(path));
  
  if (isPublicPath) {
    return NextResponse.next();
  }
  
  // Cookieからトークンを取得
  const token = request.cookies.get('auth_token')?.value;
  
  if (!token) {
    // トークンがない場合はログインページへリダイレクト
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('redirect', pathname);
    return NextResponse.redirect(loginUrl);
  }
  
  // トークンの有効期限をチェック(簡易版)
  try {
    const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
    const expiresAt = payload.exp * 1000;
    
    if (Date.now() > expiresAt) {
      const loginUrl = new URL('/login', request.url);
      return NextResponse.redirect(loginUrl);
    }
  } catch (error) {
    // トークンが不正な場合
    const loginUrl = new URL('/login', request.url);
    return NextResponse.redirect(loginUrl);
  }
  
  return NextResponse.next();
}

export const config = {
  matcher: [
    /*
     * マッチさせるパスを指定
     * APIルートと静的ファイルを除外
     */
    '/((?!_next/static|_next/image|favicon.ico|api/auth/login).*)',
  ],
};

この実装のポイントは、publicPathsで認証を不要とするパスを明示的に定義し、それ以外でトークンをチェックしています。リダイレクト時に元のパスをクエリパラメータに含めることで、ログイン後の復帰が容易になります。

実装パターン②:ロールベースアクセス制御

SaaS型のアプリケーションでは、管理画面へのアクセスを管理者のみに限定する必要があります。Middlewareで権限をチェックするパターンです。

// middleware.ts (ロール判定版)
import { NextRequest, NextResponse } from 'next/server';
import * as jose from 'jose';

const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET_KEY || 'your-secret-key'
);

interface TokenPayload {
  sub: string;
  email: string;
  role: 'user' | 'admin' | 'moderator';
  iat: number;
  exp: number;
}

async function verifyAuth(token: string): Promise {
  try {
    const verified = await jose.jwtVerify(token, JWT_SECRET);
    return verified.payload as TokenPayload;
  } catch (error) {
    return null;
  }
}

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  // 管理者ページのパターン
  const adminPaths = ['/admin', '/dashboard'];
  const isAdminPath = adminPaths.some(path => pathname.startsWith(path));
  
  // ログインページは常にアクセス可能
  if (pathname === '/login' || pathname === '/') {
    return NextResponse.next();
  }
  
  const token = request.cookies.get('auth_token')?.value;
  
  if (!token) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  
  const payload = await verifyAuth(token);
  
  if (!payload) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  
  // 管理ページへのアクセスをチェック
  if (isAdminPath && payload.role !== 'admin') {
    return NextResponse.redirect(new URL('/forbidden', request.url));
  }
  
  // リクエストヘッダーにユーザー情報を追加
  // これにより、下流のAPIハンドラで利用可能に
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.sub);
  requestHeaders.set('x-user-role', payload.role);
  
  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

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

このパターンの利点は、JWTトークンをMiddlewareで検証し、その情報をリクエストヘッダーに設定することで、ページやAPIハンドラから簡単に現在のユーザー情報にアクセスできることです。実務では、この方法でデータベース問い合わせの回数を減らしています。

実装パターン③:テナント分離と動的リダイレクト

マルチテナントのSaaSアプリケーションでは、サブドメインやパスベースでテナントを識別し、データアクセスを制御する必要があります。

// middleware.ts (テナント分離版)
import { NextRequest, NextResponse } from 'next/server';

interface TenantInfo {
  id: string;
  domain: string;
  isPremium: boolean;
}

// キャッシュ用:実務ではRedisなどを使う
const tenantCache = new Map();

async function getTenantInfo(hostname: string): Promise {
  // キャッシュから取得
  if (tenantCache.has(hostname)) {
    return tenantCache.get(hostname)!;
  }
  
  // データベースから取得(実装例)
  try {
    const response = await fetch(
      `${process.env.INTERNAL_API_URL}/tenants/by-domain?domain=${hostname}`,
      {
        headers: { 'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}` },
      }
    );
    
    if (!response.ok) return null;
    
    const tenant: TenantInfo = await response.json();
    tenantCache.set(hostname, tenant);
    return tenant;
  } catch (error) {
    console.error('Failed to fetch tenant info:', error);
    return null;
  }
}

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const pathname = request.nextUrl.pathname;
  
  // テナント情報を取得
  const tenant = await getTenantInfo(hostname);
  
  if (!tenant) {
    return NextResponse.redirect(new URL('https://example.com/not-found', request.url));
  }
  
  // プレミアムプランのみアクセス可能なページ
  const premiumPaths = ['/analytics', '/exports', '/integrations'];
  const isPremiumPath = premiumPaths.some(path => pathname.startsWith(path));
  
  if (isPremiumPath && !tenant.isPremium) {
    return NextResponse.redirect(new URL('/upgrade', request.url));
  }
  
  // テナント情報をリクエストに付与
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-tenant-id', tenant.id);
  requestHeaders.set('x-tenant-domain', tenant.domain);
  
  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

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

この実装では、ホスト名からテナントを特定し、プランに応じた機能制限を行っています。キャッシュを活用することで、毎回のデータベース問い合わせを避け、パフォーマンスを維持しています。

実装パターン④:レート制限とセキュリティ

業務アプリケーションでは、不正なアクセスやDDoS攻撃から保護する必要があります。Middlewareでシンプルなレート制限を実装できます。

// middleware.ts (レート制限版)
import { NextRequest, NextResponse } from 'next/server';

// シンプルなインメモリストア(本番ではRedisを使用)
interface RateLimitStore {
  count: number;
  resetTime: number;
}

const rateLimitMap = new Map();

function getRateLimitKey(request: NextRequest): string {
  // IPアドレスを取得
  const ip =
    request.headers.get('x-forwarded-for') ||
    request.headers.get('x-real-ip') ||
    'unknown';
  
  // ユーザーIDがあればそれも含める
  const userId = request.headers.get('x-user-id') || '';
  
  return `${ip}:${userId}`;
}

function isRateLimited(
  key: string,
  limit: number = 100,
  windowMs: number = 60000 // 1分
): boolean {
  const now = Date.now();
  const stored = rateLimitMap.get(key);
  
  if (!stored || now > stored.resetTime) {
    // 新しいウィンドウを開始
    rateLimitMap.set(key, {
      count: 1,
      resetTime: now + windowMs,
    });
    return false;
  }
  
  stored.count++;
  
  if (stored.count > limit) {
    return true; // レート制限に達した
  }
  
  return false;
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  // APIエンドポイントのみレート制限を適用
  if (!pathname.startsWith('/api/')) {
    return NextResponse.next();
  }
  
  const key = getRateLimitKey(request);
  
  // 厳しい制限が必要なエンドポイント
  if (pathname.startsWith('/api/auth/') || pathname.startsWith('/api/payments/')) {
    if (isRateLimited(key, 10, 60000)) {
      return NextResponse.json(
        { error: 'Too many requests' },
        { status: 429 }
      );
    }
  }
  
  // 通常のAPI
  if (isRateLimited(key, 100, 60000)) {
    return NextResponse.json(
      { error: 'Rate limit exceeded' },
      { status: 429 }
    );
  }
  
  // セキュリティヘッダーの追加
  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: ['/api/:path*'],
};

本番環境ではインメモリストアではなくRedisを使うべきですが、開発環境やシンプルなユースケースではこの方法で十分です。

実装パターン⑤:リクエストロギングと分析

ユーザーの行動データを収集する場合、Middlewareで全リクエストを記録することが効率的です。

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

interface RequestLog {
  timestamp: string;
  method: string;
  pathname: string;
  userId?: string;
  tenantId?: string;
  statusCode: number;
  duration: number;
  userAgent?: string;
}

async function logRequest(log: RequestLog) {
  // 非同期でログを送信(ブロッキングしない)
  try {
    await fetch(`${process.env.LOG_SERVICE_URL}/logs`, {
      method: 'POST',
      body: JSON.stringify(log),
      headers: { 'Content-Type': 'application/json' },
    }).catch(() => {
      // ログサービスが利用できない場合は無視
    });
  } catch (error) {
    // エラーログ(標準出力)
    console.error('Failed to send log:', error);
  }
}

export async function middleware(request: NextRequest) {
  const startTime = Date.now();
  const pathname = request.nextUrl.pathname;
  
  // ヘルスチェックエンドポイントはログ対象外
  if (pathname === '/health' || pathname === '/healthz') {
    return NextResponse.next();
  }
  
  try {
    const response = NextResponse.next();
    const duration = Date.now() - startTime;
    
    // リクエストログを記録
    const log: RequestLog = {
      timestamp: new Date().toISOString(),
      method: request.method,
      pathname,
      userId: request.headers.get('x-user-id') || undefined,
      tenantId: request.headers.get('x-tenant-id') || undefined,
      statusCode: response.status,
      duration,
      userAgent: request.headers.get('user-agent') || undefined,
    };
    
    // 非同期でログを送信
    logRequest(log);
    
    return response;
  } catch (error) {
    // エラー時もログを記録
    const duration = Date.now() - startTime;
    await logRequest({
      timestamp: new Date().toISOString(),
      method: request.method,
      pathname,
      userId: request.headers.get('x-user-id') || undefined,
      tenantId: request.headers.get('x-tenant-id') || undefined,
      statusCode: 500,
      duration,
      userAgent: request.headers.get('user-agent') || undefined,
    });
    
    throw error;
  }
}

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

よくある応用パターン

パターンA:地域制限

// 特定の国からのアクセスをブロック
export async function middleware(request: NextRequest) {
  const country = request.headers.get('cloudflare-country') ||
                  request.headers.get('x-vercel-ip-country');
  
  const blockedCountries = ['KP', 'IR']; // 北朝鮮、イラン
  
  if (blockedCountries.includes(country || '')) {
    return NextResponse.json(
      { error: 'Access denied from your region' },
      { status: 403 }
    );
  }
  
  return NextResponse.next();
}

パターンB:A/Bテスト

// ユーザーをグループに分け、異なるページを表示
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  if (pathname !== '/pricing') {
    return NextResponse.next();
  }
  
  // クッキーからグループを取得、なければランダムに割り当て
  let group = request.cookies.get('ab_group')?.value;
  
  if (!group) {
    group = Math.random() > 0.5 ? 'control' : 'variant';
  }
  
  const response = NextResponse.next();
  response.cookies.set('ab_group', group, {
    maxAge: 60 * 60 * 24 * 30, // 30日
    httpOnly: true,
  });
  
  // 異なるページを返す
  if (group === 'variant') {
    return NextResponse.rewrite(new URL('/pricing-v2', request.url));
  }
  
  return response;
}

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

// 特定のパスへのレスポンスをキャッシュ
export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  if (pathname.startsWith('/public/') || pathname.startsWith('/static/')) {
    const response = NextResponse.next();
    response.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
    return response;
  }
  
  if (pathname.startsWith('/blog/')) {
    const response = NextResponse.next();
    response.headers.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
    return response;
  }
  
  return NextResponse.next();
}

実装時の注意点

1. Edge Runtimeの制限を理解する

Middlewareはサーバーレス環境で実行され、次の制限があります:

  • 大きなライブラリのインポートが制限される(joseはOK、node-fetchはNG)
  • 実行時間は短く保つべき(理想は10ms以下)
  • ファイルシステムアクセスは不可

重い処理はMiddlewareでは避け、必要に応じてAPIハンドラに委譲してください。

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

全リクエストを処理するため、Middlewareのコードが遅いとサイト全体が遅くなります。実装では必ずパフォーマンスを測定し、ログなどの非同期処理は`waitUntil`を使わずバックグラウンドで実行しましょう。

3. セキュリティトークンの検証

JWTの署名検証は必ず実装してください。トークンのペイロードだけを読むのはセキュリティホールです。本記事の例では`jose`ライブラリを使い、署名検証を行っています。

4. キャッシング戦略

テナント情報やユーザー権限など、頻繁に変わらないデータはキャッシュして、Middleware実行を高速化してください。本番では必ずRedisなどの分散キャッシュを使用します。

5. エラーハンドリング

トークン検証やデータベースアクセスがエラーになる場合を考慮し、適切なエラーレスポンスを返してください。セキュリティのため、詳細なエラーメッセージは避けます。

実務で使える応用例:統合的な実装

複数のパターンを組み合わせた実務レベルの実装例です。

// middleware.ts (統合版)
import { NextRequest, NextResponse } from 'next/server';
import * as jose from 'jose';

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET_KEY || '');

interface TokenPayload {
  sub: string;
  email: string;
  role: 'user' | 'admin';
  tenantId: string;
  iat: number;
  exp: number;
}

const PUBLIC_PATHS = ['/login', '/signup', '/api/auth/login', '/api/auth/signup', '/'];
const ADMIN_PATHS = ['/admin', '/dashboard'];

async function verifyToken(token: string): Promise {
  try {
    const verified = await jose.jwtVerify(token, JWT_SECRET);
    return verified.payload as TokenPayload;
  } catch {
    return null;
  }
}

function isPublicPath(pathname: string): boolean {
  return PUBLIC_PATHS.some(path => pathname.startsWith(path));
}

function isAdminPath(pathname: string): boolean {
  return ADMIN_PATHS.some(path => pathname.startsWith(path));
}

export async function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  
  // 公開パスの場合
  if (isPublicPath(pathname)) {
    return NextResponse.next();
  }
  
  // トークン検証
  const token = request.cookies.get('auth_token')?.value;
  
  if (!token) {
    return NextResponse.redirect(
      new URL(`/login?redirect=${encodeURIComponent(pathname)}`, request.url)
    );
  }
  
  const payload = await verifyToken(token);
  
  if (!payload) {
    // トークンが無効
    const response = NextResponse.redirect(new URL('/login', request.url));
    response.cookies.delete('auth_token');
    return response;
  }
  
  // 管理者パスのチェック
  if (isAdminPath(pathname) && payload.role !== 'admin') {
    return NextResponse.redirect(new URL('/forbidden', request.url));
  }
  
  // リクエストヘッダーにユーザー情報を設定
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.sub);
  requestHeaders.set('x-user-email', payload.email);
  requestHeaders.set('x-user-role', payload.role);
  requestHeaders.set('x-tenant-id', payload.tenantId);
  
  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

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

この実装は、認証・ロール判定・テナント管理を一つのMiddlewareで処理しており、多くの業務アプリケーションの要件を満たします。

まとめ

Next.js Middlewareは、リクエストレベルでのアクセス制御や認証を実装する強力なツールです。適切に使うことで、セキュアで高速なアプリケーションを実現できます。

業務導入時の要点:

  • 認証・認可ロジックをMiddlewareで一元管理し、重複コードを避ける
  • トークン検証には署名検証を必ず含める
  • キャッシング戦略を立て、パフォーマンスを確保する
  • Edge Runtimeの制限を理解し、重い処理は避ける
  • エラーハンドリングを丁寧に実装する

本記事で示した実装パターンは、実際のプロジェクトで検証されたものです。プロジェクトの要件に応じてカスタマイズしながら活用してください。

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