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のミドルウェアは、認証・ロギング・リダイレクトなど、様々な実務要件に対応できる強力な機能です。要点をまとめると:
- シンプルに保つ:重い処理は避け、必要な場合は非同期で実行
- 適切に除外する:静的ファイルやヘルスチェックエンドポイントは除外
- 関心の分離:認証・ロギング・レート制限など、機能ごとに関数を分離
- エラーハンドリング:ミドルウェアのエラーがユーザー体験を損なわないよう注意
- 監視とログ:本番環境では外部のログサービスを活用
- テスト:ミドルウェアは独立してユニットテストを実施
実務では、プロジェクトの要件に応じてこれらのパターンを組み合わせることになります。最初はシンプルな実装から始めて、必要に応じて機能を追加していく方法をお勧めします。

