Next.js Middleware活用ガイド - 認証・リダイレクト・A/Bテストの実践パターン
Next.js Middleware活用ガイド
Next.js Middlewareは、リクエストが完了する前にコードを実行できる強力な機能です。認証、リダイレクト、A/Bテスト、地域別ルーティングなど、さまざまなユースケースで活用できます。
Middlewareの基本
セットアップ
Middlewareは middleware.ts または middleware.js ファイルをプロジェクトルートまたは src ディレクトリに配置します。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
console.log('Middleware executed for:', request.url);
return NextResponse.next();
}
// すべてのルートで実行
export const config = {
matcher: '/:path*',
};
Matcherパターン
特定のパスでのみMiddlewareを実行:
// 複数のパスパターン
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
'/admin/:path*',
],
};
// 正規表現を使用
export const config = {
matcher: [
/*
* 以下を除くすべてのパス:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
// 条件付きマッチング
export const config = {
matcher: [
{
source: '/dashboard/:path*',
has: [
{ type: 'header', key: 'x-custom-header' },
{ type: 'cookie', key: 'session' },
],
},
],
};
認証
JWT認証
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
const SECRET = new TextEncoder().encode(
process.env.JWT_SECRET || 'your-secret-key'
);
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value;
// 認証が必要なページ
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
await jwtVerify(token, SECRET);
return NextResponse.next();
} catch (error) {
// トークンが無効
const response = NextResponse.redirect(new URL('/login', request.url));
response.cookies.delete('token');
return response;
}
}
// ログイン済みユーザーがログインページにアクセス
if (request.nextUrl.pathname === '/login' && token) {
try {
await jwtVerify(token, SECRET);
return NextResponse.redirect(new URL('/dashboard', request.url));
} catch {
// トークンが無効なので続行
return NextResponse.next();
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/login'],
};
Session認証(NextAuth.js)
// middleware.ts
import { withAuth } from 'next-auth/middleware';
import { NextResponse } from 'next/server';
export default withAuth(
function middleware(req) {
// トークンが存在する場合のみ実行される追加ロジック
const token = req.nextauth.token;
const isAdmin = token?.role === 'admin';
// 管理者ページへのアクセス制御
if (req.nextUrl.pathname.startsWith('/admin') && !isAdmin) {
return NextResponse.redirect(new URL('/unauthorized', req.url));
}
return NextResponse.next();
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/profile/:path*'],
};
ロールベース認証
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyAuth } from '@/lib/auth';
type Role = 'user' | 'admin' | 'moderator';
interface RoutePermissions {
path: string;
allowedRoles: Role[];
}
const routePermissions: RoutePermissions[] = [
{ path: '/admin', allowedRoles: ['admin'] },
{ path: '/moderator', allowedRoles: ['admin', 'moderator'] },
{ path: '/dashboard', allowedRoles: ['user', 'admin', 'moderator'] },
];
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 verifyAuth(token);
const userRole = payload.role as Role;
const pathname = request.nextUrl.pathname;
// パスに対する権限チェック
const permission = routePermissions.find((p) =>
pathname.startsWith(p.path)
);
if (permission && !permission.allowedRoles.includes(userRole)) {
return NextResponse.redirect(new URL('/unauthorized', request.url));
}
// ユーザー情報をヘッダーに追加(ページで使用可能)
const response = NextResponse.next();
response.headers.set('x-user-id', payload.userId);
response.headers.set('x-user-role', userRole);
return response;
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/moderator/:path*'],
};
リダイレクト
パスベースリダイレクト
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const redirects: Record<string, string> = {
'/old-blog': '/blog',
'/old-about': '/about',
'/products/legacy': '/products',
};
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 静的リダイレクト
if (pathname in redirects) {
return NextResponse.redirect(new URL(redirects[pathname], request.url));
}
// パターンマッチングリダイレクト
if (pathname.startsWith('/old-products/')) {
const slug = pathname.replace('/old-products/', '');
return NextResponse.redirect(new URL(`/products/${slug}`, request.url));
}
return NextResponse.next();
}
条件付きリダイレクト
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const { pathname, searchParams } = request.nextUrl;
// クエリパラメータによるリダイレクト
const redirect = searchParams.get('redirect');
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url));
}
// ヘッダーベースのリダイレクト
const userAgent = request.headers.get('user-agent') || '';
const isMobile = /mobile/i.test(userAgent);
if (pathname === '/app' && isMobile) {
return NextResponse.redirect(new URL('/mobile-app', request.url));
}
// Cookieベースのリダイレクト
const preferredVersion = request.cookies.get('version')?.value;
if (pathname === '/' && preferredVersion === 'v2') {
return NextResponse.redirect(new URL('/v2', request.url));
}
return NextResponse.next();
}
永続リダイレクト
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// 一時リダイレクト (307)
if (pathname === '/temp-redirect') {
return NextResponse.redirect(new URL('/new-page', request.url), 307);
}
// 永続リダイレクト (308)
if (pathname === '/permanent-redirect') {
return NextResponse.redirect(new URL('/new-page', request.url), 308);
}
return NextResponse.next();
}
A/Bテスト
シンプルなA/Bテスト
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const COOKIE_NAME = 'ab-test-variant';
export function middleware(request: NextRequest) {
// A/Bテスト対象ページ
if (request.nextUrl.pathname === '/') {
let variant = request.cookies.get(COOKIE_NAME)?.value;
if (!variant) {
// 50/50で振り分け
variant = Math.random() < 0.5 ? 'a' : 'b';
}
const response = NextResponse.next();
// バリアントをCookieに保存
response.cookies.set(COOKIE_NAME, variant, {
maxAge: 60 * 60 * 24 * 30, // 30日間
});
// バリアント情報をヘッダーに追加
response.headers.set('x-variant', variant);
return response;
}
return NextResponse.next();
}
複数バリアントのA/Bテスト
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
interface Experiment {
name: string;
variants: {
name: string;
weight: number;
}[];
}
const experiments: Record<string, Experiment> = {
'/pricing': {
name: 'pricing-test',
variants: [
{ name: 'control', weight: 0.4 },
{ name: 'variant-a', weight: 0.3 },
{ name: 'variant-b', weight: 0.3 },
],
},
};
function selectVariant(variants: Experiment['variants']): string {
const random = Math.random();
let cumulative = 0;
for (const variant of variants) {
cumulative += variant.weight;
if (random < cumulative) {
return variant.name;
}
}
return variants[0].name;
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const experiment = experiments[pathname];
if (!experiment) {
return NextResponse.next();
}
const cookieName = `experiment-${experiment.name}`;
let variant = request.cookies.get(cookieName)?.value;
if (!variant) {
variant = selectVariant(experiment.variants);
}
const response = NextResponse.next();
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
});
response.headers.set('x-experiment', experiment.name);
response.headers.set('x-variant', variant);
return response;
}
ユーザーセグメント別A/Bテスト
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
interface UserSegment {
isReturning: boolean;
isPremium: boolean;
country?: string;
}
function getUserSegment(request: NextRequest): UserSegment {
return {
isReturning: !!request.cookies.get('returning-user')?.value,
isPremium: request.cookies.get('user-tier')?.value === 'premium',
country: request.geo?.country,
};
}
function selectVariantForSegment(segment: UserSegment): string {
// プレミアムユーザーは常にvariant-b
if (segment.isPremium) {
return 'variant-b';
}
// 新規ユーザーは50/50
if (!segment.isReturning) {
return Math.random() < 0.5 ? 'control' : 'variant-a';
}
// リピーターはvariant-a優先
return Math.random() < 0.7 ? 'variant-a' : 'control';
}
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === '/landing') {
const segment = getUserSegment(request);
const cookieName = 'landing-variant';
let variant = request.cookies.get(cookieName)?.value;
if (!variant) {
variant = selectVariantForSegment(segment);
}
const response = NextResponse.next();
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30,
});
response.headers.set('x-variant', variant);
response.headers.set('x-user-segment', JSON.stringify(segment));
return response;
}
return NextResponse.next();
}
地域別ルーティング
基本的な地域別リダイレクト
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const countryToLocale: Record<string, string> = {
US: 'en-US',
GB: 'en-GB',
JP: 'ja-JP',
FR: 'fr-FR',
DE: 'de-DE',
};
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// すでにロケールパスが含まれている場合はスキップ
const hasLocale = Object.values(countryToLocale).some((locale) =>
pathname.startsWith(`/${locale}`)
);
if (hasLocale) {
return NextResponse.next();
}
// 国コードからロケールを決定
const country = request.geo?.country || 'US';
const locale = countryToLocale[country] || 'en-US';
// ロケールパスへリダイレクト
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
ユーザー設定優先の地域別ルーティング
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const LOCALE_COOKIE = 'user-locale';
const supportedLocales = ['en-US', 'ja-JP', 'fr-FR', 'de-DE'];
function getLocale(request: NextRequest): string {
// 1. Cookieに保存されたユーザー設定
const cookieLocale = request.cookies.get(LOCALE_COOKIE)?.value;
if (cookieLocale && supportedLocales.includes(cookieLocale)) {
return cookieLocale;
}
// 2. Accept-Languageヘッダー
const acceptLanguage = request.headers.get('accept-language');
if (acceptLanguage) {
const browserLocale = acceptLanguage.split(',')[0].trim();
const matched = supportedLocales.find((locale) =>
browserLocale.startsWith(locale.split('-')[0])
);
if (matched) return matched;
}
// 3. 地理情報
const country = request.geo?.country;
const countryToLocale: Record<string, string> = {
US: 'en-US',
JP: 'ja-JP',
FR: 'fr-FR',
DE: 'de-DE',
};
if (country && countryToLocale[country]) {
return countryToLocale[country];
}
// 4. デフォルト
return 'en-US';
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// パスからロケールを抽出
const pathnameLocale = supportedLocales.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameLocale) {
// すでにロケールが指定されている
const response = NextResponse.next();
response.cookies.set(LOCALE_COOKIE, pathnameLocale, {
maxAge: 60 * 60 * 24 * 365,
});
return response;
}
// ロケールを決定してリダイレクト
const locale = getLocale(request);
const newUrl = new URL(`/${locale}${pathname}`, request.url);
return NextResponse.redirect(newUrl);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
レートリミット
シンプルなレートリミット
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// メモリ内ストア(開発用)
const rateLimit = new Map<string, { count: number; resetTime: number }>();
const LIMIT = 10; // 10リクエスト
const WINDOW = 60 * 1000; // 1分
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.ip || 'unknown';
const now = Date.now();
const userLimit = rateLimit.get(ip);
if (!userLimit || now > userLimit.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + WINDOW });
return NextResponse.next();
}
if (userLimit.count >= LIMIT) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((userLimit.resetTime - now) / 1000)),
},
}
);
}
userLimit.count++;
return NextResponse.next();
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
Upstashを使用した分散レートリミット
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// スライディングウィンドウ方式
const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, '1 m'),
analytics: true,
});
export async function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api/')) {
const ip = request.ip || 'unknown';
const { success, limit, reset, remaining } = await ratelimit.limit(ip);
const response = success
? NextResponse.next()
: NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
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;
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
エンドポイント別レートリミット
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// エンドポイントごとの設定
const rateLimits = {
'/api/auth/login': new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, '15 m'), // 15分で5回
}),
'/api/data': new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1 m'), // 1分で100回
}),
default: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(30, '1 m'), // 1分で30回
}),
};
export async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
if (pathname.startsWith('/api/')) {
const ip = request.ip || 'unknown';
// エンドポイントに応じたレートリミッターを選択
let limiter = rateLimits.default;
for (const [path, ratelimit] of Object.entries(rateLimits)) {
if (path !== 'default' && pathname.startsWith(path)) {
limiter = ratelimit;
break;
}
}
const { success, limit, reset, remaining } = await limiter.limit(
`${ip}:${pathname}`
);
if (!success) {
return NextResponse.json(
{
error: 'Too many requests',
retryAfter: new Date(reset).toISOString(),
},
{
status: 429,
headers: {
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': reset.toString(),
'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
},
}
);
}
const response = NextResponse.next();
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;
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};
パフォーマンス最適化
Middlewareのベストプラクティス
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 1. 早期リターン - 不要な処理を避ける
if (request.nextUrl.pathname.startsWith('/_next/')) {
return NextResponse.next();
}
// 2. 並列処理
const checks = Promise.all([
checkAuthentication(request),
checkRateLimit(request),
]);
// 3. 最小限のデータ処理
const response = NextResponse.next();
// 4. ヘッダーの最適化
response.headers.set('X-Request-Id', crypto.randomUUID());
return response;
}
async function checkAuthentication(request: NextRequest) {
// 認証チェック
}
async function checkRateLimit(request: NextRequest) {
// レート制限チェック
}
// 静的ファイルを除外
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
デバッグとログ
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const start = Date.now();
// リクエスト情報のログ
console.log({
timestamp: new Date().toISOString(),
method: request.method,
url: request.url,
pathname: request.nextUrl.pathname,
ip: request.ip,
userAgent: request.headers.get('user-agent'),
geo: request.geo,
});
const response = NextResponse.next();
// レスポンス時間の計測
const duration = Date.now() - start;
response.headers.set('X-Response-Time', `${duration}ms`);
console.log({
pathname: request.nextUrl.pathname,
duration: `${duration}ms`,
status: response.status,
});
return response;
}
まとめ
Next.js Middlewareは、リクエスト/レスポンスのフローに介入できる強力な機能です。
重要ポイント
- 認証: セッション管理とロールベースアクセス制御
- リダイレクト: 条件付きルーティングとURL正規化
- A/Bテスト: ユーザーセグメント別の実験
- 地域別ルーティング: 自動ロケール検出と切り替え
- レートリミット: API保護とリソース管理
ベストプラクティス
- Matcherを適切に設定して不要な実行を避ける
- 重い処理は避け、必要に応じて非同期処理を使用
- エラーハンドリングを適切に実装
- ログを活用してデバッグを容易にする
Middlewareを活用することで、アプリケーションのセキュリティ、パフォーマンス、ユーザー体験を大幅に向上させることができます。