Next.js の middleware.ts は、リクエストがルートハンドラーやページに届く前に走る、地味だけどかなり頼れるフックです。認証、国際化(i18n)、A/B テスト、ジオロケーションでの出し分け……エッジで終わらせたい処理を一箇所にまとめられるのが大きな魅力。とはいえ「どこまで Middleware に詰め込んでいいの?」「Edge Runtime と Node.js Runtime、結局どっち?」「matcher をミスって全リクエストで Middleware が動いちゃった」みたいな落とし穴も多くて、公式ドキュメントを読むだけでは腑に落ちない場面も増えてきました。
そこで本記事では、Next.js 16(App Router 前提)と React 19 をベースに、2026 年時点で本番投入できる Middleware パターンをひととおり整理していきます。サンプルコードは全部、コピペでそのまま動くようにしてあるので、手元で試しながら読んでもらえれば。
Next.js Middleware の基本構造とライフサイクル
Middleware はプロジェクトルートに置いた middleware.ts(または src/middleware.ts)で定義します。リクエストごとに 1 回だけ呼ばれて、NextResponse を返すことで以下のいずれかを行えます。
NextResponse.next():そのまま通すNextResponse.redirect(url):別 URL へリダイレクトNextResponse.rewrite(url):URL を内部的に書き換え(ユーザーには元 URL のまま見える)NextResponse.json(body, init)/new NextResponse(body, init):レスポンスを直接返す
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const start = Date.now();
const response = NextResponse.next();
response.headers.set('x-middleware-elapsed', String(Date.now() - start));
return response;
}
export const config = {
matcher: [
// _next/static, _next/image, favicon, API ルート以外で実行
'/((?!_next/static|_next/image|favicon.ico|api).*)',
],
};
ここがポイント:Middleware はリクエストごとに必ず実行されます。だからこそ重い処理を入れると TTFB(Time To First Byte)に直撃するんですよね。データベースアクセスや外部 API 呼び出しは原則 NG。どうしても必要なら、後述する Node.js Runtime に切り替えるか、Route Handler / Server Component 側に処理を寄せましょう。
Edge Runtime と Node.js Runtime の選び方(Next.js 15.2+)
以前は Middleware = Edge Runtime 一択でした。Vercel Edge や Cloudflare Workers の V8 isolate 上で動くため、fs、net、crypto といった Node.js API、ネイティブモジュールは使えない、というかなりキツい制約があったんです。
それが Next.js 15.2 で Node.js Runtime オプションがベータ提供され、Next.js 16 でついに安定版になりました。「jose じゃなくて使い慣れた jsonwebtoken を使いたい」「Prisma で DB を直接引きたい」といったケースで、Node.js Runtime を素直に選べるようになっています。
// middleware.ts
export const config = {
matcher: ['/admin/:path*'],
// 'nodejs' を指定すると Node.js Runtime で実行される
runtime: 'nodejs',
};
選び方のざっくりガイドラインはこんな感じです。
| 観点 | Edge Runtime | Node.js Runtime |
|---|---|---|
| レイテンシ | 世界各地のエッジで実行・低遅延 | サーバーリージョン依存 |
| コールドスタート | ほぼゼロ | 数十〜数百 ms |
| 使える API | Web 標準のみ | Node.js 全 API |
| npm パッケージ | Edge 互換のみ | 制限なし |
| JWT 検証 | jose 推奨 | jsonwebtoken も可 |
| 料金(Vercel) | Edge Function 課金 | Serverless Function 課金 |
原則は単純で、「軽量な認可・リダイレクト・ヘッダー操作」は Edge、「Prisma で DB を直接引く」「重い暗号処理」は Node.js。これだけ覚えておけば、だいたいの場面で迷わずに済みます。
matcher の書き方と落とし穴
matcher はビルド時に静的解析されるので、文字列リテラル(または文字列リテラルの配列)で書く必要があります。動的に組み立てたパターンはまるっと無視されます。これ、地味にハマるポイントなので注意。
除外ベースの matcher(推奨)
「全パスから静的アセットと API を除く」のが、いちばんよく使う書き方です。
export const config = {
matcher: [
/*
* 以下を除いた全てに一致:
* - api(API ルート)
* - _next/static(静的ファイル)
* - _next/image(画像最適化)
* - favicon.ico、robots.txt、sitemap.xml
* - 拡張子を含むファイル(画像・フォントなど)
*/
'/((?!api|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\..*).*)',
],
};
条件付き matcher
Next.js 13.5 以降、matcher はオブジェクト形式で has / missing を受け取れます。「Cookie がある時だけ Middleware を動かす」みたいな条件を宣言的に書けて、しかも高速。
export const config = {
matcher: [
{
source: '/dashboard/:path*',
has: [{ type: 'cookie', key: 'session' }],
},
{
source: '/login',
missing: [{ type: 'cookie', key: 'session' }],
},
],
};
あるあるミス:matcher: '/' と書くと「ルートだけ」になります。サブページでは Middleware が走りません(私も最初これで一度ハマりました)。全ページを対象にしたいなら、除外型の正規表現を使いましょう。
パターン 1:JWT を使った認証ミドルウェア
たぶん一番需要が高いユースケース、保護されたルートへのアクセス制御です。Edge Runtime では jose を使うのが事実上の標準。
// 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!);
const PUBLIC_PATHS = ['/login', '/signup', '/api/auth'];
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
return NextResponse.next();
}
const token = request.cookies.get('session')?.value;
if (!token) {
return redirectToLogin(request);
}
try {
const { payload } = await jwtVerify(token, SECRET, {
issuer: 'auto-content',
audience: 'web',
});
// 検証済みクレームを下流のハンドラに渡す
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-user-id', String(payload.sub));
requestHeaders.set('x-user-role', String(payload.role ?? 'user'));
return NextResponse.next({ request: { headers: requestHeaders } });
} catch {
return redirectToLogin(request);
}
}
function redirectToLogin(request: NextRequest) {
const url = request.nextUrl.clone();
url.pathname = '/login';
url.searchParams.set('callbackUrl', request.nextUrl.pathname);
return NextResponse.redirect(url);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
Server Component 側では headers() から x-user-id を取れるので、毎回 JWT を再検証する必要がありません。ここが Middleware を使う最大のメリット、と個人的には思っています。
セッションクッキーのリフレッシュ
JWT が短命(たとえば 15 分)でリフレッシュトークンと併用するパターンなら、Middleware 内でサイレント更新ができます。
// 認証成功時
const response = NextResponse.next({ request: { headers: requestHeaders } });
// 期限が近い場合は新しいトークンを発行(このリクエストでは検証済みのまま)
if (shouldRefresh(payload.exp)) {
const newToken = await refreshAccessToken(token);
response.cookies.set('session', newToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 15,
});
}
return response;
パターン 2:国際化(i18n)ルーティング
App Router だと next-intl や next-i18next を使うことが多いんですが、自前で言語検出をやりたい場合は Middleware で Accept-Language を見て rewrite するのが定石です。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';
const locales = ['ja', 'en', 'es', 'fr'] as const;
const defaultLocale = 'ja';
function getLocale(request: NextRequest): string {
const headers: Record = {};
request.headers.forEach((v, k) => (headers[k] = v));
const languages = new Negotiator({ headers }).languages();
return matchLocale(languages, locales as unknown as string[], defaultLocale);
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// すでにロケールがパスに含まれていれば何もしない
const hasLocale = locales.some(
(l) => pathname.startsWith(`/${l}/`) || pathname === `/${l}`
);
if (hasLocale) return NextResponse.next();
// Cookie 優先、なければ Accept-Language から推定
const cookieLocale = request.cookies.get('NEXT_LOCALE')?.value;
const locale =
cookieLocale && locales.includes(cookieLocale as typeof locales[number])
? cookieLocale
: getLocale(request);
const url = request.nextUrl.clone();
url.pathname = `/${locale}${pathname}`;
return NextResponse.redirect(url);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
SEO 観点では、redirect(301/302)と rewrite(URL 維持)の使い分けが重要です。検索エンジンに各言語版を別 URL として認識させたいなら redirect、内部的なエイリアスでよければ rewrite。ここを間違えるとインデックスがぐちゃぐちゃになるので慎重に。
パターン 3:A/B テストとフィーチャーフラグ
Middleware と Cookie を組み合わせると、JS バンドルを増やさずにエッジで A/B テストを回せます。クライアント側のチラつき(フリッカー)が出ないのが、この方式のいちばんのご褒美。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const BUCKET_COOKIE = 'ab-bucket';
const BUCKETS = ['control', 'variant-a', 'variant-b'] as const;
function pickBucket(): typeof BUCKETS[number] {
// 33/33/34 で分割
const r = Math.random();
if (r < 0.33) return 'control';
if (r < 0.66) return 'variant-a';
return 'variant-b';
}
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname !== '/pricing') {
return NextResponse.next();
}
const existing = request.cookies.get(BUCKET_COOKIE)?.value as
| typeof BUCKETS[number]
| undefined;
const bucket = existing && BUCKETS.includes(existing) ? existing : pickBucket();
// バケットに応じて URL を rewrite(ユーザーには /pricing のまま見える)
const url = request.nextUrl.clone();
if (bucket !== 'control') {
url.pathname = `/pricing-${bucket}`;
}
const response = NextResponse.rewrite(url);
if (!existing) {
response.cookies.set(BUCKET_COOKIE, bucket, {
maxAge: 60 * 60 * 24 * 30,
path: '/',
sameSite: 'lax',
});
}
return response;
}
export const config = { matcher: ['/pricing'] };
app ディレクトリに pricing/page.tsx、pricing-variant-a/page.tsx、pricing-variant-b/page.tsx を用意するだけで完成。CDN キャッシュは Cookie 単位でセグメント化されるので、Vercel なら Vary ヘッダーの設定で安全に運用できます。
パターン 4:ジオロケーションによる出し分け
Vercel のエッジでは request.geo から国コード・地域・都市が取れます(Cloudflare では cf オブジェクトに相当)。ただし Next.js 15 以降は request.geo が deprecate されていて、geolocation() ヘルパーまたは x-vercel-ip-country ヘッダーを使う形に移行しています。古い記事のコピペには要注意。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { geolocation } from '@vercel/functions';
export function middleware(request: NextRequest) {
const { country = 'JP' } = geolocation(request);
// 価格ページを国別 rewrite
if (request.nextUrl.pathname === '/pricing') {
const url = request.nextUrl.clone();
url.pathname = `/pricing/${country.toLowerCase()}`;
return NextResponse.rewrite(url);
}
// GDPR 対象国にバナー表示用ヘッダーを付与
const GDPR_COUNTRIES = ['DE', 'FR', 'IT', 'ES', 'NL'];
if (GDPR_COUNTRIES.includes(country)) {
const response = NextResponse.next();
response.headers.set('x-show-gdpr', '1');
return response;
}
return NextResponse.next();
}
パターン 5:レスポンスヘッダー(CSP・セキュリティ)の付与
Strict CSP を nonce 付きで適用するのも、Middleware の典型的な使い道です。Next.js 公式ドキュメントでも推奨されているパターン。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' blob: data:`,
`font-src 'self'`,
`object-src 'none'`,
`base-uri 'self'`,
`frame-ancestors 'none'`,
`upgrade-insecure-requests`,
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
requestHeaders.set('content-security-policy', csp);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('content-security-policy', csp);
return response;
}
Server Component 側で headers().get('x-nonce') を読み、<Script nonce=...> に渡すことでインラインスクリプトを安全に許可できます。
パターン 6:レート制限(Edge Runtime)
Upstash Redis の @upstash/ratelimit はエッジ互換なので、Middleware 内で完結する高速なレート制限が組めます。インフラを足さずに済むのがありがたい。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(60, '1 m'),
analytics: true,
});
export async function middleware(request: NextRequest) {
if (!request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.next();
}
const ip =
request.headers.get('x-forwarded-for')?.split(',')[0].trim() ?? 'anon';
const { success, limit, remaining, reset } = await ratelimit.limit(ip);
const response = success
? NextResponse.next()
: NextResponse.json({ error: 'Too Many Requests' }, { status: 429 });
response.headers.set('x-ratelimit-limit', String(limit));
response.headers.set('x-ratelimit-remaining', String(remaining));
response.headers.set('x-ratelimit-reset', String(reset));
return response;
}
export const config = { matcher: '/api/:path*' };
複数の関心事を組み合わせる:コンポーザブル Middleware
正直なところ、Middleware は 1 ファイルしか持てないので、認証・i18n・A/B テストを全部入れるとファイルがどんどん肥大化します。関数チェーンで分割するのが、現実的なベストプラクティスです。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { withAuth } from './middleware/auth';
import { withI18n } from './middleware/i18n';
import { withSecurityHeaders } from './middleware/security';
type MW = (
req: NextRequest,
next: () => Promise<NextResponse>
) => Promise<NextResponse>;
function chain(...mws: MW[]) {
return async (req: NextRequest) => {
let i = -1;
const dispatch = async (): Promise<NextResponse> => {
i++;
const mw = mws[i];
if (!mw) return NextResponse.next();
return mw(req, dispatch);
};
return dispatch();
};
}
export const middleware = chain(withSecurityHeaders, withI18n, withAuth);
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};
各関数は next() を呼ぶか、自分でレスポンスを返すかを選べる Express 風のシグネチャ。テストも単体で書きやすくなります。
パフォーマンスとデバッグの実践 Tips
- そもそも Middleware を呼ばないのが最強:静的アセットや画像のパスは
matcherから確実に除外しましょう。漏れると 1 ページの表示で数十回 Middleware が走ります(実際にやらかしたことがあります……)。 - Edge では同期処理を意識:
awaitは必要ですが、無駄な Promise.all やリトライは TTFB を悪化させるだけ。 - キャッシュ可能なヘッダーを壊さない:
Set-Cookieを毎回付けると CDN がキャッシュをスキップします。Cookie の更新は条件分岐の中だけにすること。 - 計測:
x-middleware-elapsedをレスポンスヘッダーに付けておくと、本番でブラウザの DevTools から実行時間が見えるので便利です。 - ローカルデバッグ:
console.logはターミナルに出ますが、Edge Runtime ではスタックトレースが省略されることがあります。console.error(new Error().stack)で明示的に出力すると追跡しやすいです。 - Vercel の Observability:Edge Function ログは Vercel ダッシュボードの Logs タブにストリームされます。本番では
request.headers.get('x-vercel-id')をログに含めて、相関を取れるようにしておきましょう。
Middleware で「やってはいけないこと」
- Prisma など Node 限定の重い ORM を Edge Runtime で動かす:ビルドエラーになるか、ランタイムで失敗します。Node.js Runtime に切り替えるか、Server Component に処理を寄せましょう。
- Middleware 内でレスポンスボディを読む:Middleware は POST のボディを
request.bodyとして受け取れますが、ストリームを消費してしまうとダウンストリームに渡せません。基本はヘッダーと URL に基づく判断のみ、と割り切ったほうが事故りません。 - 外部 API 呼び出しでブロッキング:認可で外部に問い合わせるなら、JWT のように検証鍵だけ持って自分で完結する設計に倒すのが正解。
- 大きな依存関係をインポート:Edge Function のバンドルサイズには制限(Vercel では 1 MB)があります。
lodash全部、momentなどは避けて、軽量な代替を選びましょう。
FAQ:Next.js Middleware についてよくある質問
Q. Middleware と Server Action / Route Handler はどう使い分ければいい?
A. ルーティング前に効かせたい横断的関心事(認証チェック、リダイレクト、ヘッダー付与、A/B 分岐)は Middleware、ビジネスロジックや DB 書き込みは Server Action か Route Handler、というのが鉄則です。Middleware では「通すか、リダイレクトするか、書き換えるか」だけを判断する。データを返す処理は持たせない、と覚えておくと迷いません。
Q. Middleware で Cookie を読み書きするとキャッシュは効かなくなる?
A. リクエスト Cookie を「読むだけ」ならキャッシュには影響しません。でも、レスポンスに Set-Cookie を付けると Vercel の CDN はキャッシュをスキップします。Cookie の更新は条件付き(新規ユーザーや期限切れ時)にして、毎リクエストで set しない設計にしましょう。
Q. Edge Runtime で jsonwebtoken がエラーになるのはなぜ?
A. jsonwebtoken は Node.js の crypto モジュールに依存していて、Edge Runtime では動きません。Edge では Web Crypto API を使う jose を使うか、Middleware を runtime: 'nodejs' に切り替えてください。署名鍵は TextEncoder で Uint8Array 化してから渡すのを忘れずに(私はこれで 30 分溶かしました)。
Q. matcher が効かないのですが?
A. ありがちな原因は次の 3 つです。①パスのフォーマットミス('/api/:path*' は OK ですが '/api/*' は NG)、②matcher を変数経由で動的に組み立てている(ビルド時に静的解析されるためリテラルでないと無視)、③除外パターンの漏れで _next/data などにヒットしている。request.nextUrl.pathname をログに出して、想定外のパスが入っていないか確認しましょう。
Q. App Router と Pages Router の Middleware は違いますか?
A. middleware.ts の API 自体は共通で、両方のルーターをまたいで動作します。違うのは下流側で受け取る方法。App Router では Server Component 内で headers() から、Pages Router では getServerSideProps の context.req.headers から、Middleware が付けたヘッダーを読みます。Next.js 16 では App Router の利用が公式の推奨です。
まとめ
Next.js Middleware は「軽量・宣言的・エッジ実行」という制約のおかげで、本来サーバー内のあちこちに散らばっていた認可・国際化・分岐ロジックを一箇所に集約できる、けっこう強力な仕組みです。2026 年時点では Node.js Runtime オプションの安定化により、Edge と Node.js を関心事ごとに使い分ける運用が、もう完全に主流になりました。
本記事で紹介したパターンは、どれも本番運用で実際に使われている構成です。まずは認証ミドルウェア+セキュリティヘッダー付与の最小構成から始めて、必要に応じて i18n や A/B テストをコンポーザブル Middleware として積み重ねていく。この順番なら、保守性とパフォーマンスを両立しやすいはずです。