Vercel Edge Functions実践:Middlewareで認証・リダイレクト・A/Bを捌く
Vercel Edge Runtimeの制約とMiddlewareの使い方を実コードで解説。認証・国別リダイレクト・A/Bテスト、いつEdgeでいつNodeか、Claude Codeでのレビュー観点まで。
「Edgeに置けば速くなるんでしょ?」と軽い気持ちでWebhook受けをEdge Functionに移したら、本番でいきなり500が出ました。
ローカルでは通っていたんです。原因はcrypto.createHmac。署名検証のためにNodeのcryptoを呼んでいたんですが、Edge Runtimeはそれを知らない。ビルドは通る。デプロイも通る。リクエストが来た瞬間に死ぬ。いちばん気づきにくいタイプの事故でした。
Vercel Edgeは確かに便利な道具です。でも「Node.jsのつもりで書くと壊れる別の実行環境」だと分かっていないと、僕みたいに本番で踏みます。この記事では、Edge Runtimeで何ができて何ができないか、Middlewareで認証・リダイレクト・A/Bテストをどう捌くか、そしていつEdgeでいつNodeに残すかを、コピペで動くコードと一緒に整理します。
この記事の要点
- Edge RuntimeはNode.jsではない。
fetch・Request・Response・URL・Web CryptoなどのWeb標準APIが中心で、fsやファイルシステムは使えない。requireも不可(importを使う)。 - Middlewareは「全ページの関所」。国別リダイレクト・A/Bバケット・軽い認証など、ヘッダーとCookieだけで決まる判断に向く。詰め込みすぎるとサイト全体が遅くなる。
- 重い処理はEdgeに置かない。DBへの直接接続・決済・メール送信・長いLLMストリームはNode.js側に残す。Edgeは入口で分類して内部APIやキューへ渡す。
- Vercelは今、Edge→Node.jsへの移行を公式に推奨している。「Edgeなら万能で速い」は誤解。データソースが遠ければ結局遅い。
- Claude Codeには「Node専用APIの混入を探して」と頼む。
fs・node:crypto・TCP接続前提のDBクライアントなど、ビルドでは気づけない依存を先に潰せる。
Edge RuntimeはNode.jsじゃない、という大前提
まずここが全部の土台です。Edge Runtimeは、ユーザーの近くで小さなJavaScriptを動かす実行環境ですが、中身はNode.jsではなくV8エンジン上の隔離環境です。ブラウザでもNodeでもない、第三の場所だと思ってください。
身近な例えで言うと、コンビニのレジ前にいる案内係みたいなものです。商品の在庫を倉庫まで見に行ったり、宅配便を発送したりはしない。「お探しのものはあちらです」「会員証はお持ちですか」と、その場ですぐ判断することだけをやる。重い仕事は奥のバックヤード(Node.js側)に任せる。これがEdgeの正しい使い方です。
使えるAPIはWeb標準が中心です。具体的にはfetch、Request、Response、Headers、URL、URLSearchParams、TextEncoder/TextDecoder、そして暗号まわりのWeb Crypto(crypto.subtle)。逆に、Vercel公式ドキュメントがはっきり「使えない」と書いているのがこのあたりです。
- ファイルシステムの読み書き(
fs系)は不可。 requireでの読み込みは不可。import(ESモジュール)を使う。evalやnew Function(文字列)のような動的コード実行は、セキュリティ上の理由で禁止。実行時エラーになる。- Nodeの多くのAPIに依存したライブラリは、そのままだと動かない。
ひとつ注意したいのがBufferです。少し前の感覚だと「EdgeにはBufferがない」と覚えている人が多いんですが、2026年6月時点のVercelドキュメントではBufferはグローバルに公開されていて、events・buffer・assert・util・async_hooksといった一部のNodeモジュールもimportできるようになっています。とはいえ署名検証のような暗号処理は、移植性を考えてBuffer+crypto.createHmacではなくWeb Cryptoで書くのが安全です。後でCloudflare WorkersやDenoに動かすときもそのまま通ります。
サーバーレス関数の概念そのものを整理したい人はサーバーレス関数(FaaS)とは?4大プラットフォームの選び方、CDN・エッジ関数・オリジンの違いから入りたい人はエッジコンピューティングとは?CDN・エッジ関数・オリジンの違いを先に読むと、この記事がスッと入ります。
いつEdgeで、いつNodeに残すか
ここを最初に決めないと、生成AIに書かせたコードがどんどんNode専用APIに寄っていきます。判断基準はシンプルで、DBを深く読まず、ヘッダー・URL・Cookie・短い本文だけで決まる処理ならEdge、それ以外はNodeです。
| 処理 | Edgeに置く理由 | Node.js側に残すもの |
|---|---|---|
| 国別リダイレクト | x-vercel-ip-countryヘッダーで入口を即振り分けられる | ユーザー設定の永続化、地域別価格の計算 |
| A/Bテスト | Cookieでバケットを決め、ページ到達前にヘッダーへ渡せる | 集計、統計判定、実験停止の判断 |
| 軽い認証・署名チェック | 管理画面・プレビュー・Webhookを早い段階で遮断できる | セッション発行、権限管理、監査ログ保存 |
| キャッシュ前処理 | クエリを正規化してキャッシュキーを安定させられる | キャッシュ再生成、DB更新、在庫計算 |
| Webhook受け | 小さな本文を検証して内部APIやキューへ渡せる | 重い決済処理、メール送信、リトライ管理 |
Claude Codeに依頼するときは、この表をそのまま依頼文に貼るのがおすすめです。「Edgeに置く処理」と「Nodeに残す処理」を先に渡しておくと、fs・Buffer・TCP接続前提のDBクライアントに寄った失敗コードが目に見えて減ります。
処理の流れはこんなイメージです。Edgeを「最終処理の場所」にしないのがコツで、入口で分類し、怪しいリクエストを落とし、必要なメタデータを付けて、重い処理は奥へ渡します。
flowchart LR
A["ユーザーのリクエスト"] --> B["Next.js Middleware"]
B --> C{"小さな判断"}
C --> D["国別リダイレクト"]
C --> E["A/Bバケット"]
C --> F["軽い認証"]
B --> G["Edge Route Handler"]
G --> H["HMAC署名チェック"]
H --> I["内部API / キュー"]
Middlewareで認証・リダイレクト・A/Bを捌く
Middlewareは、Next.jsアプリの全リクエストがページやAPIに届く前に通る「関所」です。Vercel上ではEdgeで動くので、ここに国別リダイレクト・A/Bテスト・軽い認証をまとめると効きます。
下がmiddleware.tsの最小構成です。ポイントは、request.geoのような壊れやすいAPIに頼らず、Vercelが付けるx-vercel-ip-countryヘッダーを読んでいること。ローカルではこのヘッダーが入らないので、国別分岐は本番かPreview環境で確認します。
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
// 静的ファイルは関所を素通りさせる(無駄にMiddlewareを走らせない)
const PUBLIC_FILE = /\.(?:png|jpg|jpeg|gif|svg|webp|ico|css|js|map|txt)$/i;
const SECRET_HEADER = "x-edge-shared-secret";
export const config = {
// Webloook用APIと内部アセットは除外。ここを広げすぎると全リクエストが重くなる
matcher: ["/((?!api/webhooks|_next/static|_next/image|favicon.ico).*)"],
};
// A/Bのバケット決め。Cookieがあれば固定、なければ乱数で振り分ける
function chooseBucket(request: NextRequest): "a" | "b" {
const current = request.cookies.get("ab_bucket")?.value;
if (current === "a" || current === "b") return current;
const random = new Uint8Array(1);
crypto.getRandomValues(random); // Web Crypto。Nodeのcryptoは使わない
return random[0] < 128 ? "a" : "b";
}
// 国コードから言語へのざっくりマッピング
function localeFromCountry(country: string | null): string | null {
switch (country?.toUpperCase()) {
case "JP":
return "ja";
case "KR":
return "ko";
case "CN":
case "TW":
case "HK":
return "zh";
case "BR":
return "pt";
case "ES":
case "MX":
return "es";
default:
return null;
}
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
if (PUBLIC_FILE.test(pathname)) {
return NextResponse.next();
}
// トップに来た人だけ、国に合わせて言語トップへ振る
if (pathname === "/") {
const country = request.headers.get("x-vercel-ip-country");
const locale = localeFromCountry(country);
if (locale) {
return NextResponse.redirect(new URL(`/${locale}/`, request.url), 307);
}
}
// /beta 配下はA/Bテスト。バケットをヘッダーで後段に渡す
if (pathname.startsWith("/beta")) {
const bucket = chooseBucket(request);
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-ab-bucket", bucket);
const response = NextResponse.next({
request: { headers: requestHeaders },
});
// 初回だけCookieを発行。毎リクエスト書き込まないのが大事
if (!request.cookies.has("ab_bucket")) {
response.cookies.set("ab_bucket", bucket, {
maxAge: 60 * 60 * 24 * 30,
path: "/",
sameSite: "lax",
secure: request.nextUrl.protocol === "https:",
});
}
return response;
}
// /preview 配下は共有シークレットを持つ人だけ通す(軽い認証)
if (pathname.startsWith("/preview")) {
const expected = process.env.EDGE_SHARED_SECRET;
const actual = request.headers.get(SECRET_HEADER);
if (!expected || actual !== expected) {
return NextResponse.redirect(new URL("/login", request.url), 307);
}
}
// 通すリクエストにはセキュリティヘッダーを足しておく
const response = NextResponse.next();
response.headers.set("x-content-type-options", "nosniff");
response.headers.set("referrer-policy", "strict-origin-when-cross-origin");
return response;
}
このコードで意識しているのは、Middlewareに仕事を詰め込まないことです。A/Bテストはバケットを決めるだけで集計はしない。プレビュー認証は「共有シークレットがあるか」を見るだけで、権限管理や監査ログは後段のサーバー側に任せる。Middlewareは全ページの入口を通るので、ここで重い処理を1つ挟むと、サイト全体がその分遅くなります。リダイレクトループを作ると全ページが死ぬのも、Middlewareならではの怖さです。
Edge Route HandlerでWebhookの署名を検証する
冒頭で僕が踏んだcrypto.createHmacの事故、あれを正しく書き直すとこうなります。app/api/webhooks/provider/route.tsで、NodeのcryptoやBufferに頼らず、Web CryptoとTextEncoderだけでHMAC署名を検証する例です。HMACは「共有した秘密鍵と本文から作る、改ざん検知用の署名」のことです。
// app/api/webhooks/provider/route.ts
export const runtime = "edge";
export const preferredRegion = ["iad1", "hnd1"]; // 米国東部と東京で実行
const MAX_BODY_BYTES = 256_000; // 本文サイズの上限。Edgeで巨大本文は受けない
// "sha256=..." 形式の16進文字列をバイト列に変換する
function hexToBytes(hex: string): Uint8Array {
const clean = hex.replace(/^sha256=/, "").trim();
if (!/^[0-9a-f]+$/i.test(clean) || clean.length % 2 !== 0) {
return new Uint8Array();
}
const bytes = new Uint8Array(clean.length / 2);
for (let index = 0; index < clean.length; index += 2) {
bytes[index / 2] = Number.parseInt(clean.slice(index, index + 2), 16);
}
return bytes;
}
// Web CryptoでHMAC-SHA256を計算する(Nodeのcrypto.createHmacの代わり)
async function hmacSha256(secret: string, payload: string): Promise<Uint8Array> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
return new Uint8Array(signature);
}
// タイミング攻撃を避けるため、長さに依存しない比較をする
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let index = 0; index < a.length; index += 1) {
diff |= a[index] ^ b[index];
}
return diff === 0;
}
export async function POST(request: Request) {
const secret = process.env.WEBHOOK_SECRET;
const internalOrigin = process.env.INTERNAL_API_ORIGIN;
const internalToken = process.env.INTERNAL_API_TOKEN;
// 環境変数が欠けていたら早めに落とす
if (!secret || !internalOrigin || !internalToken) {
return Response.json({ error: "server is not configured" }, { status: 500 });
}
// まずヘッダーのサイズで弾く(本文を読む前のガード)
const contentLength = Number(request.headers.get("content-length") ?? "0");
if (contentLength > MAX_BODY_BYTES) {
return Response.json({ error: "payload too large" }, { status: 413 });
}
// 署名は「生の本文」に対して検証する。先にJSON.parseしてはいけない
const rawBody = await request.text();
const rawBodyBytes = new TextEncoder().encode(rawBody);
if (rawBodyBytes.byteLength > MAX_BODY_BYTES) {
return Response.json({ error: "payload too large" }, { status: 413 });
}
const provided = hexToBytes(request.headers.get("x-signature-sha256") ?? "");
const expected = await hmacSha256(secret, rawBody);
if (!constantTimeEqual(provided, expected)) {
return Response.json({ error: "invalid signature" }, { status: 401 });
}
// ここまで来て初めてJSONとして扱う
const event = JSON.parse(rawBody) as { id?: string; type?: string };
if (!event.id || !event.type) {
return Response.json({ error: "invalid event" }, { status: 400 });
}
// 重い処理はEdgeで続けず、内部APIへ丸投げする
await fetch(`${internalOrigin}/api/webhook-events`, {
method: "POST",
headers: {
authorization: `Bearer ${internalToken}`,
"content-type": "application/json",
},
body: JSON.stringify({
id: event.id,
type: event.type,
receivedAt: new Date().toISOString(),
}),
});
return Response.json({ ok: true });
}
設計の勘どころは3つあります。1つ目は本文サイズを先に見ること。Edgeは大きな本文や長いストリーミングに向きません。2つ目は署名検証の前にJSONをパースしないこと。プロバイダーが署名したのは「生の本文」なので、整形した文字列だと検証がずれます。3つ目は検証後は内部APIへ渡すだけにすること。決済確定・メール送信・CRM更新のような副作用は、再試行や監査がしやすいNode.js側で扱います。
ちなみにVercelのEdgeには時間の制約もあって、Middlewareもこの種の関数も最初のレスポンスは25秒以内に返し始める必要があります(その後のストリーミングは最長300秒)。コードサイズもプランごとに上限があり、gzip後でHobby 1MB / Pro 2MB / Enterprise 4MBです。重いライブラリを丸ごとimportすると、ここに引っかかります。
Claude Codeに渡すレビュー指示と最小テスト
Edgeのコードは「動いた/動かない」がローカルで分かりにくいぶん、Claude Codeに壊れる依存を探させるのが効きます。機能追加だけでなく、見るべきファイル・禁止API・検証コマンド・ログの扱いまで指定すると、レビューの粒度が安定します。
このNext.jsのEdge実装をレビューして。
対象:
- middleware.ts
- app/api/webhooks/provider/route.ts
- 関連テストと環境変数名
チェック項目:
- Edgeファイルに fs, net, tls, node:crypto などNode専用APIが混ざっていないか
- Edge RuntimeからDBへ直接接続していないか
- 国別リダイレクトがループしないか
- A/BバケットがCookieで固定され、毎リクエスト書き込まれていないか
- WebhookがJSONパースの前に生の本文で署名検証しているか
- secret・署名・Cookie・Authorizationヘッダーをログに出していないか
- 本文サイズ上限と、本番限定のVercelヘッダーが明記されているか
まずブロッカーを挙げ、その後に追加すべきテストを提案して。
ローカル確認では、Edge内ではなく「署名を作る補助スクリプト」としてNodeを使ってかまいません。次が最小の動作確認です。
npm run lint
vercel dev
BODY='{"id":"evt_123","type":"checkout.completed"}'
SIG=$(node -e "const crypto=require('crypto'); const body=process.argv[1]; console.log('sha256='+crypto.createHmac('sha256', process.env.WEBHOOK_SECRET).update(body).digest('hex'))" "$BODY")
curl -i http://localhost:3000/api/webhooks/provider \
-X POST \
-H "content-type: application/json" \
-H "x-signature-sha256: $SIG" \
--data "$BODY"
curl -I http://localhost:3000/beta
curl -I http://localhost:3000/preview
本番確認は別物だと割り切ってください。国別ヘッダー・Cookieのsecure挙動・リダイレクト・実際のリージョン挙動は、vercel devでは完全には再現できません。x-vercel-ip-countryはローカルだと付かないので、国別分岐はPreview Deploymentで見ます。Claude Codeに「ローカルで確認できること」と「Previewでしか確認できないこと」を分けてリスト化させると、公開前レビューがかなり楽になります。
僕がEdgeでやらかした落とし穴
正直に書きます。最初のEdge移行は事故だらけでした。
ひとつ目は冒頭のNode専用APIの混入。crypto.createHmacに始まり、pathやfsを当たり前に書いてしまう。ビルドは通るので気づけない。署名はWeb Cryptoで、と最初に決めてからは踏まなくなりました。
ふたつ目はDB接続をEdgeから直に持ったこと。ユーザーの近くで実行しても、DBが東京の1リージョンにあれば結局そこへ往復します。接続数も無駄に増える。Edgeでやるのはキャッシュキー作成・署名検証・不要リクエストの遮断までにして、重い読み書きはNode.js Functionや専用APIに渡すのが運用しやすいです。
みっつ目は**「Edgeなら速い」と思い込んだこと**。Vercelは今、ドキュメントの先頭で「性能と信頼性のためにEdgeからNode.jsへの移行を推奨します」とまで書いています。Edgeは近いリージョンで実行できるだけで、依存するデータソースが遠ければ遅い。preferredRegionを指定しても、何がどこで動くかはログと計測で確認しないと分かりません。
よっつ目はログ漏えい。デバッグのつもりでWebhook本文・署名・Cookie・Authorizationヘッダーをconsole.logしたら、それがPreviewログや外部のログ基盤に残ります。Claude Codeには「デバッグログを入れて」ではなく「機密値をマスクしたログだけ入れて」と頼むようにしました。
このあたりはCloudflare WorkersでKV/D1/R2を触るCloudflare Workers入門でも同じ罠が出ます。Web標準APIで書く癖をつけると、プラットフォームを跨いでも壊れにくくなります。
よくある質問
Q. Edge FunctionsとMiddlewareは何が違うんですか?
A. Middlewareは「全リクエストがページ/APIに届く前に通る関所」で、リダイレクトやヘッダー書き換えが主な仕事です。Edge Route Handler(runtime = "edge"を付けたAPI)は「特定のエンドポイントのレスポンスを返す」もの。どちらもVercel上ではEdge Runtimeで動きます。入口の振り分けはMiddleware、個別のAPI応答はRoute Handler、と分けると整理しやすいです。
Q. Edge RuntimeでBufferは本当に使えないんですか?
A. 昔は使えませんでしたが、今のVercelドキュメントではBufferはグローバルに公開され、buffer・events・utilなどのNodeモジュールもimportできます。ただし全Node APIが揃ったわけではなく、fsなどは依然不可です。移植性を考えるなら、暗号処理はBuffer+cryptoではなくWeb Cryptoで書くのが無難です。
Q. データベースにEdgeから接続してもいいですか? A. TCP接続を前提とする普通のDBクライアントはEdgeで動きません。HTTPベースのDBアクセス(各社が出しているHTTPドライバやデータAPI)なら可能ですが、DBが1リージョンにあると近接の意味が薄れます。基本は「Edgeで判断、重いDBアクセスはNode側」に寄せるのが安全です。
Q. vercel devで全部テストできますか?
A. できません。x-vercel-ip-countryのような国別ヘッダー、Cookieのsecure挙動、実際のリージョン挙動はローカルでは再現されません。署名検証やA/Bの基本ロジックはローカルで、国別分岐やリージョン依存はPreview Deploymentで、と分けて確認します。
Q. 結局いつEdgeを使えばいいんですか? A. ヘッダー・URL・Cookie・短い本文だけで判断が完結し、ユーザーの近くで即決したい処理のときです。具体的には国別リダイレクト・A/Bバケット決め・軽い認証・Webhookの一次受け。逆に重いDB・決済・メール・長いLLM処理が絡むなら、Vercelの推奨どおりNode.jsランタイムを選んでください。
実際に試した結果
この構成で、Middlewareはリダイレクト・A/Bバケット・軽い認証だけに絞り、Webhookは署名検証後に内部APIへ渡す形で動かしてみました。やってみていちばん効いたのは、コード量を減らしたことではなく**「Edgeでやらないこと」を先に決めた**点でした。
冒頭のcrypto.createHmac事故以来、僕はEdgeのコードを書く前に必ず「これはヘッダーとCookieだけで決まるか?」と一回自問するようになりました。決まらないならNodeに残す。それだけでデプロイ後の500がほぼ消えました。Claude Codeにレビュー指示を渡すと、Bufferやnode:cryptoの混入、署名検証前のJSONパース、Previewでしか確認できない国別ヘッダーの見落としを、本番に出る前に拾ってくれます。
Edge Functionsは魔法の高速化ボタンではありません。でも「入口の小さな判断」だけを置くと割り切れば、Vercel上で安全に効く実務部品になります。チームで運用ルールまで詰めたい場合はClaude Code研修・導入相談で扱っているので、必要なら覗いてみてください。
公式の制約は変わることがあるので、最新はVercelのEdge Runtime公式ドキュメントで確認するのが確実です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。