DevToolBoxFREE
BlogAdvertise

Next.js Middleware:認証、リダイレクト、レート制限

14分by DevToolBox

Next.js ミドルウェアはリクエスト完了前に実行され、Edge Runtime で動作するため、認証チェック、ジオリダイレクト、A/Bテスト、レート制限に最適です。

Next.js ミドルウェアとは?

ミドルウェアはリクエストがページに到達する前に実行される関数です。

// Request flow in Next.js
//
// Client Request
//     │
//     ▼
// ┌─────────────┐
// │  Middleware  │  ← Runs here (Edge Runtime)
// └─────────────┘
//     │
//     ▼
// ┌─────────────┐
// │  Page/API    │  ← Your application code
// └─────────────┘
//     │
//     ▼
// Client Response

基本セットアップ

プロジェクトルートに middleware.ts を作成します。

// middleware.ts (project root)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Log every request
  console.log(`[${request.method}] ${request.nextUrl.pathname}`);

  // Continue to the next handler
  return NextResponse.next();
}

// Only run on specific paths
export const config = {
  matcher: ['/dashboard/:path*', '/api/:path*'],
};

Matcher の設定

matcher はどのルートでミドルウェアが実行されるかを決定します。

// Match specific routes
export const config = {
  matcher: ['/dashboard/:path*', '/account/:path*'],
};

// Match all routes except static files and API
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - api (API routes)
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     */
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

// Multiple matchers with different patterns
export const config = {
  matcher: [
    '/dashboard/:path*',     // All dashboard routes
    '/admin/:path*',         // All admin routes
    '/api/protected/:path*', // Protected API routes
    '/profile',              // Exact match
  ],
};

// Conditional matching with has/missing (Next.js 13.1+)
export const config = {
  matcher: [
    {
      source: '/api/:path*',
      has: [
        { type: 'header', key: 'authorization' },
      ],
    },
  ],
};

認証ミドルウェア

認証が必要なルートの保護に最も一般的に使用されます。

JWT トークン検証

ミドルウェアで JWT を直接検証します。

// middleware.ts — JWT Authentication
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';

const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET!
);

const PUBLIC_PATHS = ['/login', '/register', '/forgot-password'];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Skip public paths
  if (PUBLIC_PATHS.some(path => pathname.startsWith(path))) {
    return NextResponse.next();
  }

  // Get token from cookie or Authorization header
  const token =
    request.cookies.get('auth-token')?.value ||
    request.headers.get('authorization')?.replace('Bearer ', '');

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

  try {
    // Verify JWT token
    const { payload } = await jwtVerify(token, JWT_SECRET);

    // Add user info to headers for downstream use
    const response = NextResponse.next();
    response.headers.set('x-user-id', payload.sub as string);
    response.headers.set('x-user-role', payload.role as string);
    return response;
  } catch (error) {
    // Token expired or invalid — redirect to login
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('from', pathname);
    const response = NextResponse.redirect(loginUrl);
    response.cookies.delete('auth-token');
    return response;
  }
}

export const config = {
  matcher: ['/dashboard/:path*', '/account/:path*', '/api/protected/:path*'],
};

ロールベースのアクセス制御

ユーザーロールに基づくアクセス制御を実施します。

// Role-based access control in middleware
const ROUTE_PERMISSIONS: Record<string, string[]> = {
  '/admin':        ['admin'],
  '/dashboard':    ['admin', 'editor', 'viewer'],
  '/editor':       ['admin', 'editor'],
  '/api/admin':    ['admin'],
};

function hasPermission(pathname: string, role: string): boolean {
  for (const [route, roles] of Object.entries(ROUTE_PERMISSIONS)) {
    if (pathname.startsWith(route)) {
      return roles.includes(role);
    }
  }
  return true; // No restriction for unmatched routes
}

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;
  if (!token) return NextResponse.redirect(new URL('/login', request.url));

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    const role = payload.role as string;
    const { pathname } = request.nextUrl;

    if (!hasPermission(pathname, role)) {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }

    return NextResponse.next();
  } catch {
    return NextResponse.redirect(new URL('/login', request.url));
  }
}

リダイレクトパターン

さまざまな条件に基づくリダイレクトに最適です。

地理ベースのリダイレクト

ジオロケーションデータでユーザーをリダイレクトします。

// Geo-based redirect (Vercel Edge)
export function middleware(request: NextRequest) {
  const country = request.geo?.country || 'US';
  const city = request.geo?.city || 'Unknown';

  // Redirect EU users to GDPR-compliant page
  const EU_COUNTRIES = [
    'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI',
    'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU',
    'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',
  ];

  if (EU_COUNTRIES.includes(country) &&
      request.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL('/eu', request.url));
  }

  // Add geo headers for downstream use
  const response = NextResponse.next();
  response.headers.set('x-country', country);
  response.headers.set('x-city', city);
  return response;
}

ロケール検出とリダイレクト

ユーザーの言語設定を検出してリダイレクトします。

// Locale detection middleware
const SUPPORTED_LOCALES = ['en', 'fr', 'de', 'es', 'ja', 'ko', 'zh'];
const DEFAULT_LOCALE = 'en';

function getPreferredLocale(request: NextRequest): string {
  // Check cookie first (user preference)
  const cookieLocale = request.cookies.get('locale')?.value;
  if (cookieLocale && SUPPORTED_LOCALES.includes(cookieLocale)) {
    return cookieLocale;
  }

  // Parse Accept-Language header
  const acceptLang = request.headers.get('accept-language') || '';
  const preferred = acceptLang
    .split(',')
    .map(lang => lang.split(';')[0].trim().substring(0, 2))
    .find(lang => SUPPORTED_LOCALES.includes(lang));

  return preferred || DEFAULT_LOCALE;
}

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Check if locale is already in the path
  const hasLocale = SUPPORTED_LOCALES.some(
    locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (hasLocale) return NextResponse.next();

  // Redirect to locale-prefixed path
  const locale = getPreferredLocale(request);
  const url = new URL(`/${locale}${pathname}`, request.url);
  return NextResponse.redirect(url);
}

レート制限

エッジでのレート制限を実装します。

// Simple rate limiting with in-memory store
// Note: This works per-edge-node; use Redis for distributed rate limiting

const rateLimit = new Map<string, { count: number; resetTime: number }>();

const RATE_LIMIT = 100;       // requests
const RATE_WINDOW = 60 * 1000; // per minute

function isRateLimited(ip: string): boolean {
  const now = Date.now();
  const record = rateLimit.get(ip);

  if (!record || now > record.resetTime) {
    rateLimit.set(ip, { count: 1, resetTime: now + RATE_WINDOW });
    return false;
  }

  record.count++;
  return record.count > RATE_LIMIT;
}

export function middleware(request: NextRequest) {
  // Only rate-limit API routes
  if (!request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.next();
  }

  const ip = request.headers.get('x-forwarded-for')?.split(',')[0] ||
             request.ip ||
             '127.0.0.1';

  if (isRateLimited(ip)) {
    return new NextResponse(
      JSON.stringify({ error: 'Too many requests' }),
      {
        status: 429,
        headers: {
          'Content-Type': 'application/json',
          'Retry-After': '60',
        },
      }
    );
  }

  return NextResponse.next();
}

スライディングウィンドウ

スライディングウィンドウアルゴリズムを使用します。

// Rate limiting with Upstash Redis (production-ready)
// npm install @upstash/ratelimit @upstash/redis

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 requests per 10 seconds
  analytics: true,
  prefix: 'api-ratelimit',
});

export async function middleware(request: NextRequest) {
  if (!request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.next();
  }

  const ip = request.ip ?? '127.0.0.1';
  const { success, limit, reset, remaining } = await ratelimit.limit(ip);

  const response = success
    ? NextResponse.next()
    : new NextResponse(JSON.stringify({ error: 'Rate limit exceeded' }), {
        status: 429,
        headers: { 'Content-Type': 'application/json' },
      });

  // Always set rate limit headers
  response.headers.set('X-RateLimit-Limit', limit.toString());
  response.headers.set('X-RateLimit-Remaining', remaining.toString());
  response.headers.set('X-RateLimit-Reset', reset.toString());

  return response;
}

レスポンスヘッダーとセキュリティ

セキュリティヘッダーの設定に最適な場所です。

セキュリティヘッダー

// Security headers middleware
export function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Prevent XSS attacks
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'"
  );

  // Prevent clickjacking
  response.headers.set('X-Frame-Options', 'DENY');

  // Prevent MIME type sniffing
  response.headers.set('X-Content-Type-Options', 'nosniff');

  // Enable HSTS
  response.headers.set(
    'Strict-Transport-Security',
    'max-age=63072000; includeSubDomains; preload'
  );

  // Referrer policy
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');

  // Permissions policy
  response.headers.set(
    'Permissions-Policy',
    'camera=(), microphone=(), geolocation=()'
  );

  return response;
}

CORS ヘッダー

ミドルウェアレベルで CORS を処理します。

// CORS middleware for API routes
const ALLOWED_ORIGINS = [
  'https://myapp.com',
  'https://staging.myapp.com',
  process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : '',
].filter(Boolean);

export function middleware(request: NextRequest) {
  if (!request.nextUrl.pathname.startsWith('/api/')) {
    return NextResponse.next();
  }

  const origin = request.headers.get('origin') || '';
  const isAllowed = ALLOWED_ORIGINS.includes(origin);

  // Handle preflight OPTIONS request
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': isAllowed ? origin : '',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
        'Access-Control-Max-Age': '86400',
      },
    });
  }

  const response = NextResponse.next();
  if (isAllowed) {
    response.headers.set('Access-Control-Allow-Origin', origin);
    response.headers.set('Access-Control-Allow-Credentials', 'true');
  }
  return response;
}

A/B テスト

ちらつきのないサーバーサイド A/B テストを実現します。

// A/B Testing middleware
export function middleware(request: NextRequest) {
  // Only A/B test the homepage
  if (request.nextUrl.pathname !== '/') {
    return NextResponse.next();
  }

  // Check if user already has a variant assigned
  const variant = request.cookies.get('ab-variant')?.value;

  if (variant) {
    // Rewrite to the variant page without changing URL
    return NextResponse.rewrite(
      new URL(`/variants/${variant}`, request.url)
    );
  }

  // Assign a random variant (50/50 split)
  const newVariant = Math.random() < 0.5 ? 'control' : 'experiment';

  const response = NextResponse.rewrite(
    new URL(`/variants/${newVariant}`, request.url)
  );

  // Persist variant in cookie for 30 days
  response.cookies.set('ab-variant', newVariant, {
    maxAge: 60 * 60 * 24 * 30,
    httpOnly: true,
    sameSite: 'lax',
  });

  return response;
}

URL リライト

ブラウザの URL を変更せずにリライトします。

// Multi-tenant rewriting
// app.mysite.com → /tenants/app
// docs.mysite.com → /tenants/docs
export function middleware(request: NextRequest) {
  const hostname = request.headers.get('host') || '';
  const subdomain = hostname.split('.')[0];

  if (subdomain && subdomain !== 'www' && subdomain !== 'mysite') {
    return NextResponse.rewrite(
      new URL(`/tenants/${subdomain}${request.nextUrl.pathname}`, request.url)
    );
  }

  return NextResponse.next();
}

// Proxy API requests to external service
// /api/external/* → https://api.external-service.com/*
export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/api/external/')) {
    const path = request.nextUrl.pathname.replace('/api/external', '');
    return NextResponse.rewrite(
      new URL(`https://api.external-service.com${path}`)
    );
  }
  return NextResponse.next();
}

ミドルウェアの連鎖

複数のミドルウェア関数を合成します。

// Composing multiple middleware functions
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

type MiddlewareFn = (
  request: NextRequest,
  response: NextResponse
) => NextResponse | Promise<NextResponse>;

function chain(
  functions: MiddlewareFn[],
  request: NextRequest
): Promise<NextResponse> {
  return functions.reduce(
    async (promise, fn) => {
      const response = await promise;
      return fn(request, response);
    },
    Promise.resolve(NextResponse.next())
  );
}

// Individual middleware functions
const withLogging: MiddlewareFn = (request, response) => {
  console.log(`[${request.method}] ${request.nextUrl.pathname}`);
  return response;
};

const withSecurityHeaders: MiddlewareFn = (request, response) => {
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  return response;
};

const withAuth: MiddlewareFn = (request, response) => {
  const token = request.cookies.get('auth-token')?.value;
  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }
  return response;
};

// Compose all middleware
export function middleware(request: NextRequest) {
  return chain([withLogging, withSecurityHeaders, withAuth], request);
}

Edge Runtime の制限

Edge Runtime にはいくつかの制限があります。

  • ファイルシステムアクセス不可
  • ネイティブ Node.js モジュール不可
  • Web API のみ
  • バンドルサイズ制限 1MB
  • データベース接続不可
  • 実行時間制限
// What you CAN use in Edge Runtime:
// ✅ fetch, Request, Response, Headers, URL
// ✅ TextEncoder, TextDecoder
// ✅ crypto.subtle (Web Crypto API)
// ✅ structuredClone
// ✅ atob, btoa
// ✅ URLPattern
// ✅ EdgeDB, Upstash, PlanetScale drivers

// What you CANNOT use:
// ❌ fs (file system)
// ❌ path, os, child_process
// ❌ pg, mysql2, mongoose (traditional DB drivers)
// ❌ bcrypt (use bcryptjs or Web Crypto instead)
// ❌ Large npm packages that exceed 1MB bundle

ミドルウェアのデバッグ

デバッグテクニックを紹介します。

// Debugging middleware
export function middleware(request: NextRequest) {
  // Log request details
  console.log({
    method: request.method,
    url: request.url,
    pathname: request.nextUrl.pathname,
    cookies: Object.fromEntries(request.cookies.getAll().map(c => [c.name, c.value])),
    headers: Object.fromEntries(request.headers),
    geo: request.geo,
    ip: request.ip,
  });

  // Return debug info in response headers (dev only)
  const response = NextResponse.next();
  if (process.env.NODE_ENV === 'development') {
    response.headers.set('x-middleware-pathname', request.nextUrl.pathname);
    response.headers.set('x-middleware-matched', 'true');
  }
  return response;
}

ベストプラクティス

  1. ミドルウェアを軽量に。
  2. 静的ファイルを除外。
  3. DB 呼び出しを避ける。
  4. キャッシュヘッダーを設定。
  5. A/B テストに Cookie を使用。
  6. エラーハンドリングを実装。
  7. 異なるシナリオをテスト。
  8. 環境変数を使用。

結論

Next.js ミドルウェアはエッジで動作する強力なツールです。認証、リダイレクト、レート制限などのパターンをマスターしましょう。

FAQ

ミドルウェアはすべてのリクエストで実行される?

デフォルトではそうですが、matcher で制限すべきです。

ミドルウェアでデータベースを使える?

直接は不可。fetch で API ルートを呼び出してください。

redirect と rewrite の違いは?

redirect は URL を変更、rewrite は URL を変更せずにコンテンツを変更。

ミドルウェアのエラー処理は?

try-catch でラップし、エラー時は適切なレスポンスを返します。

𝕏 Twitterin LinkedIn
この記事は役に立ちましたか?

Stay Updated

Get weekly dev tips and new tool announcements.

No spam. Unsubscribe anytime.

Partner Picks

Sponsor this article

Place your product next to this developer topic with tracked clicks.

Ask about article sponsorship

Try These Related Tools

This site uses cookies for analytics and to display ads. By continuing to browse, you agree. Privacy Policy