最終更新:

Next.js Middleware設計パターン: 認証・リダイレクト・ABテストの実装


Next.js Middleware設計パターン

Next.js Middlewareは、リクエストが完了する前にコードを実行できる強力な機能ですが、適切な設計パターンを理解することで、さらに効果的に活用できます。

本記事では、実践的な設計パターン、マルチテナント対応、エッジ最適化、セキュリティ強化など、一歩進んだMiddlewareの使い方を解説します。

Middlewareアーキテクチャ

単一Middlewareパターン

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

// すべてのロジックを1つのファイルに集約
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl

  // 1. 認証チェック
  if (pathname.startsWith('/dashboard')) {
    const token = request.cookies.get('auth-token')
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }

  // 2. リダイレクトルール
  if (pathname === '/old-page') {
    return NextResponse.redirect(new URL('/new-page', request.url))
  }

  // 3. ヘッダー追加
  const response = NextResponse.next()
  response.headers.set('x-custom-header', 'value')

  return response
}

// 問題点:
// - コードが長くなると保守性が低下
// - テストが困難
// - 再利用性が低い

モジュール化パターン(推奨)

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'
import { authMiddleware } from './middlewares/auth'
import { redirectMiddleware } from './middlewares/redirect'
import { securityMiddleware } from './middlewares/security'
import { analyticsMiddleware } from './middlewares/analytics'

// Middleware関数を合成するヘルパー
function chain(
  middlewares: NextMiddleware[],
  index = 0
): NextMiddleware {
  const current = middlewares[index]

  if (current) {
    const next = chain(middlewares, index + 1)
    return async (request) => {
      const result = await current(request)
      if (result instanceof Response) {
        return result
      }
      return next(request)
    }
  }

  return () => NextResponse.next()
}

// Middlewareを組み合わせ
export default chain([
  securityMiddleware,
  authMiddleware,
  redirectMiddleware,
  analyticsMiddleware,
])

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

個別Middlewareの実装

// middlewares/auth.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'
import { verifyToken } from '@/lib/auth'

const protectedRoutes = [
  '/dashboard',
  '/profile',
  '/settings',
]

export const authMiddleware: NextMiddleware = async (request: NextRequest) => {
  const { pathname } = request.nextUrl

  // 保護されたルートかチェック
  const isProtected = protectedRoutes.some((route) =>
    pathname.startsWith(route)
  )

  if (!isProtected) {
    return NextResponse.next()
  }

  const token = request.cookies.get('auth-token')?.value

  if (!token) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('callbackUrl', pathname)
    return NextResponse.redirect(loginUrl)
  }

  try {
    const payload = await verifyToken(token)

    // ユーザー情報をヘッダーに追加
    const response = NextResponse.next()
    response.headers.set('x-user-id', payload.userId)
    response.headers.set('x-user-role', payload.role)

    return response
  } catch (error) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('error', 'invalid_token')
    const response = NextResponse.redirect(loginUrl)
    response.cookies.delete('auth-token')
    return response
  }
}
// middlewares/security.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

export const securityMiddleware: NextMiddleware = (request: NextRequest) => {
  const response = NextResponse.next()

  // セキュリティヘッダーを追加
  response.headers.set('X-Frame-Options', 'DENY')
  response.headers.set('X-Content-Type-Options', 'nosniff')
  response.headers.set('X-XSS-Protection', '1; mode=block')
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
  response.headers.set(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=()'
  )
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';"
  )

  return response
}
// middlewares/redirect.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

interface RedirectRule {
  source: string | RegExp
  destination: string
  permanent?: boolean
}

const redirectRules: RedirectRule[] = [
  { source: '/old-blog', destination: '/blog', permanent: true },
  { source: '/old-about', destination: '/about', permanent: true },
  { source: /^\/products\/(.*)$/, destination: '/shop/$1', permanent: false },
]

export const redirectMiddleware: NextMiddleware = (request: NextRequest) => {
  const { pathname } = request.nextUrl

  for (const rule of redirectRules) {
    if (typeof rule.source === 'string') {
      if (pathname === rule.source) {
        return NextResponse.redirect(
          new URL(rule.destination, request.url),
          rule.permanent ? 308 : 307
        )
      }
    } else {
      const match = pathname.match(rule.source)
      if (match) {
        const destination = rule.destination.replace(/\$(\d+)/g, (_, index) => {
          return match[index] || ''
        })
        return NextResponse.redirect(
          new URL(destination, request.url),
          rule.permanent ? 308 : 307
        )
      }
    }
  }

  return NextResponse.next()
}

マルチテナント対応

サブドメインベース

// middlewares/tenant.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

interface Tenant {
  id: string
  subdomain: string
  customDomain?: string
}

// テナント情報を取得(KVストアやDBから)
async function getTenant(hostname: string): Promise<Tenant | null> {
  // 例: Vercel KV
  const kv = await import('@vercel/kv')
  return kv.get(`tenant:${hostname}`)
}

export const tenantMiddleware: NextMiddleware = async (request: NextRequest) => {
  const hostname = request.headers.get('host') || ''
  const subdomain = hostname.split('.')[0]

  // ローカル開発環境
  if (hostname.includes('localhost')) {
    const response = NextResponse.next()
    response.headers.set('x-tenant-id', 'dev')
    return response
  }

  // カスタムドメインをチェック
  let tenant = await getTenant(hostname)

  // サブドメインをチェック
  if (!tenant && subdomain) {
    tenant = await getTenant(subdomain)
  }

  if (!tenant) {
    return NextResponse.redirect(new URL('https://www.example.com/404', request.url))
  }

  // テナント情報をヘッダーに追加
  const response = NextResponse.rewrite(
    new URL(`/${tenant.id}${request.nextUrl.pathname}`, request.url)
  )
  response.headers.set('x-tenant-id', tenant.id)
  response.headers.set('x-tenant-subdomain', tenant.subdomain)

  return response
}

パスベース

// middlewares/tenant-path.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

export const tenantPathMiddleware: NextMiddleware = async (
  request: NextRequest
) => {
  const { pathname } = request.nextUrl

  // パターン: /org/[tenant-slug]/...
  const match = pathname.match(/^\/org\/([^\/]+)(.*)$/)

  if (!match) {
    return NextResponse.next()
  }

  const [, tenantSlug, rest] = match

  // テナント存在確認
  const tenant = await getTenantBySlug(tenantSlug)

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

  // テナント情報をヘッダーに追加
  const response = NextResponse.next()
  response.headers.set('x-tenant-id', tenant.id)
  response.headers.set('x-tenant-slug', tenantSlug)

  return response
}

async function getTenantBySlug(slug: string) {
  // DB検索など
  return { id: '123', slug }
}

動的ルーティング

国際化(i18n)

// middlewares/i18n.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'
import Negotiator from 'negotiator'
import { match as matchLocale } from '@formatjs/intl-localematcher'

const locales = ['en', 'ja', 'fr', 'de', 'zh']
const defaultLocale = 'en'

function getLocale(request: NextRequest): string {
  // 1. パスからロケール抽出
  const pathname = request.nextUrl.pathname
  const pathnameLocale = locales.find(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
  if (pathnameLocale) return pathnameLocale

  // 2. Cookieからロケール取得
  const cookieLocale = request.cookies.get('locale')?.value
  if (cookieLocale && locales.includes(cookieLocale)) {
    return cookieLocale
  }

  // 3. Accept-Languageヘッダーから判定
  const negotiatorHeaders: Record<string, string> = {}
  request.headers.forEach((value, key) => {
    negotiatorHeaders[key] = value
  })

  const languages = new Negotiator({ headers: negotiatorHeaders }).languages()
  const locale = matchLocale(languages, locales, defaultLocale)

  return locale
}

export const i18nMiddleware: NextMiddleware = (request: NextRequest) => {
  const { pathname } = request.nextUrl

  // 静的ファイルをスキップ
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api') ||
    /\.(ico|png|jpg|jpeg|svg|css|js)$/.test(pathname)
  ) {
    return NextResponse.next()
  }

  // パスにロケールが含まれているかチェック
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )

  if (pathnameHasLocale) {
    return NextResponse.next()
  }

  // ロケールを判定してリダイレクト
  const locale = getLocale(request)
  const newUrl = new URL(`/${locale}${pathname}${request.nextUrl.search}`, request.url)

  const response = NextResponse.redirect(newUrl)
  response.cookies.set('locale', locale, { maxAge: 60 * 60 * 24 * 365 })

  return response
}

デバイス別ルーティング

// middlewares/device.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

function getDeviceType(userAgent: string): 'mobile' | 'tablet' | 'desktop' {
  const ua = userAgent.toLowerCase()

  if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
    return 'tablet'
  }

  if (
    /mobile|android|iphone|ipod|blackberry|windows phone/i.test(ua)
  ) {
    return 'mobile'
  }

  return 'desktop'
}

export const deviceMiddleware: NextMiddleware = (request: NextRequest) => {
  const userAgent = request.headers.get('user-agent') || ''
  const deviceType = getDeviceType(userAgent)
  const { pathname } = request.nextUrl

  // モバイル専用パスへリダイレクト
  if (deviceType === 'mobile' && !pathname.startsWith('/m/')) {
    // 特定のページのみモバイル版にリダイレクト
    const mobilePages = ['/shop', '/products', '/cart']
    const shouldRedirect = mobilePages.some((page) =>
      pathname.startsWith(page)
    )

    if (shouldRedirect) {
      return NextResponse.redirect(new URL(`/m${pathname}`, request.url))
    }
  }

  // デバイス情報をヘッダーに追加
  const response = NextResponse.next()
  response.headers.set('x-device-type', deviceType)

  return response
}

エッジ最適化

エッジキャッシュ制御

// middlewares/cache.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

interface CacheConfig {
  path: string | RegExp
  maxAge: number
  sMaxAge?: number
  staleWhileRevalidate?: number
}

const cacheConfigs: CacheConfig[] = [
  {
    path: /^\/api\/public\//,
    maxAge: 60,
    sMaxAge: 3600,
    staleWhileRevalidate: 86400,
  },
  {
    path: '/blog',
    maxAge: 300,
    sMaxAge: 3600,
  },
  {
    path: /^\/static\//,
    maxAge: 31536000, // 1年
  },
]

export const cacheMiddleware: NextMiddleware = (request: NextRequest) => {
  const { pathname } = request.nextUrl

  for (const config of cacheConfigs) {
    const matches =
      typeof config.path === 'string'
        ? pathname.startsWith(config.path)
        : config.path.test(pathname)

    if (matches) {
      const response = NextResponse.next()

      const cacheControl = [
        `public`,
        `max-age=${config.maxAge}`,
        config.sMaxAge && `s-maxage=${config.sMaxAge}`,
        config.staleWhileRevalidate &&
          `stale-while-revalidate=${config.staleWhileRevalidate}`,
      ]
        .filter(Boolean)
        .join(', ')

      response.headers.set('Cache-Control', cacheControl)

      return response
    }
  }

  return NextResponse.next()
}

エッジ関数の最適化

// middlewares/edge-optimize.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

// エッジで実行される軽量な処理のみ
export const edgeOptimizeMiddleware: NextMiddleware = async (
  request: NextRequest
) => {
  const start = Date.now()

  // 1. 早期リターン(不要な処理をスキップ)
  if (request.nextUrl.pathname.startsWith('/_next/')) {
    return NextResponse.next()
  }

  // 2. 並列処理
  const [geoData, timeData] = await Promise.all([
    getGeoData(request),
    getServerTime(),
  ])

  // 3. 最小限のヘッダー追加
  const response = NextResponse.next()
  response.headers.set('x-geo-country', geoData.country)
  response.headers.set('x-server-time', timeData.toString())

  const duration = Date.now() - start
  response.headers.set('x-middleware-duration', `${duration}ms`)

  return response
}

async function getGeoData(request: NextRequest) {
  return {
    country: request.geo?.country || 'US',
    city: request.geo?.city || 'Unknown',
  }
}

async function getServerTime() {
  return new Date().toISOString()
}

// エッジランタイム設定
export const config = {
  runtime: 'edge',
}

高度なセキュリティパターン

CSRFトークン検証

// middlewares/csrf.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'
import { createHash } from 'crypto'

const CSRF_SECRET = process.env.CSRF_SECRET || 'default-secret'

function generateToken(sessionId: string): string {
  return createHash('sha256')
    .update(`${sessionId}${CSRF_SECRET}`)
    .digest('hex')
}

function verifyToken(sessionId: string, token: string): boolean {
  const expectedToken = generateToken(sessionId)
  return token === expectedToken
}

export const csrfMiddleware: NextMiddleware = (request: NextRequest) => {
  const { pathname, searchParams } = request.nextUrl
  const method = request.method

  // GETリクエストはスキップ
  if (method === 'GET') {
    return NextResponse.next()
  }

  // APIルートのみ検証
  if (!pathname.startsWith('/api/')) {
    return NextResponse.next()
  }

  const sessionId = request.cookies.get('session-id')?.value
  const csrfToken = request.headers.get('x-csrf-token')

  if (!sessionId || !csrfToken) {
    return NextResponse.json(
      { error: 'Missing CSRF token' },
      { status: 403 }
    )
  }

  if (!verifyToken(sessionId, csrfToken)) {
    return NextResponse.json(
      { error: 'Invalid CSRF token' },
      { status: 403 }
    )
  }

  return NextResponse.next()
}

Bot検出

// middlewares/bot-detection.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

const knownBots = [
  'googlebot',
  'bingbot',
  'slurp',
  'duckduckbot',
  'baiduspider',
  'yandexbot',
  'sogou',
  'exabot',
  'facebot',
  'ia_archiver',
]

const suspiciousBots = [
  'scrapy',
  'python-requests',
  'curl',
  'wget',
  'go-http-client',
]

function isSuspiciousBot(userAgent: string): boolean {
  const ua = userAgent.toLowerCase()
  return suspiciousBots.some((bot) => ua.includes(bot))
}

function isKnownBot(userAgent: string): boolean {
  const ua = userAgent.toLowerCase()
  return knownBots.some((bot) => ua.includes(bot))
}

export const botDetectionMiddleware: NextMiddleware = (request: NextRequest) => {
  const userAgent = request.headers.get('user-agent') || ''

  // 検索エンジンは許可
  if (isKnownBot(userAgent)) {
    const response = NextResponse.next()
    response.headers.set('x-bot-type', 'search-engine')
    return response
  }

  // 疑わしいBotはブロック
  if (isSuspiciousBot(userAgent)) {
    return NextResponse.json(
      { error: 'Access denied' },
      { status: 403 }
    )
  }

  return NextResponse.next()
}

パフォーマンス監視

レスポンスタイム計測

// middlewares/performance.ts
import { NextResponse } from 'next/server'
import type { NextRequest, NextMiddleware } from 'next/server'

export const performanceMiddleware: NextMiddleware = async (
  request: NextRequest
) => {
  const start = performance.now()

  const response = NextResponse.next()

  const duration = performance.now() - start

  response.headers.set('x-response-time', `${duration.toFixed(2)}ms`)
  response.headers.set('x-request-id', crypto.randomUUID())

  // 遅いリクエストをログ
  if (duration > 1000) {
    console.warn(`Slow request: ${request.nextUrl.pathname} took ${duration}ms`)
  }

  return response
}

まとめ

Next.js Middlewareの高度な設計パターンを理解することで、以下が実現できます。

主な利点

  1. 保守性の向上 - モジュール化により管理が容易
  2. 再利用性 - 共通ロジックを複数プロジェクトで利用
  3. テスト容易性 - 個別Middlewareを独立してテスト
  4. パフォーマンス - エッジ最適化で高速化
  5. セキュリティ - 一元的なセキュリティ制御

ベストプラクティス

  • Middlewareは軽量に保つ
  • エッジランタイムの制限を理解する
  • 早期リターンで不要な処理をスキップ
  • 並列処理を活用
  • 適切なエラーハンドリング

これらのパターンを活用して、スケーラブルで保守性の高いNext.jsアプリケーションを構築しましょう。