Next.js App Router で壊れる Webhook ハンドラーの最大の原因は生ボディです。App Router は問題ありません。route handler も問題ありません。しかしパースされていないリクエストボディを取得する規約が Pages Router と App Router で変わり、ほとんどの Stripe / GitHub / Shopify 連携は古い世界のスニペットをコピーしています。この記事は新しい世界です。
2 行のルール
App Router の route handler では await request.text() で生ボディを取得します。署名検証の前に request.json() を使わないこと。Pages Router でやったように bodyParser を無効化しようとしないこと——そのフラグはもう存在しません。
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const body = await request.text(); // raw, not parsed
const signature = (await headers()).get('stripe-signature')!;
let event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
return NextResponse.json(
{ error: 'invalid signature' },
{ status: 400 },
);
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed':
// ...
break;
}
return NextResponse.json({ received: true });
}
3 行に注目:request.text() は Stripe が署名した生の文字列を与えます。App Router の headers() は async です——await に注意。そして署名検証は JSON.parse 相当の前に行う必要があります。
忘れるべき Pages Router の習慣
Pages Router から移行したなら、これを書いたのを覚えているでしょう:
// pages/api/webhooks/stripe.ts (OLD)
export const config = { api: { bodyParser: false } };
その config は App Router に存在しません。ボディは Request オブジェクトからオンデマンドで読まれます。2 番目に読んだものは空——request.json() のあとの request.text() は何も返しません。1 つを選び、Webhook では常に text() を選んでください。
Next.js dev のトンネル設定
next dev(またはnpm run dev)を実行——通常ポート 3000。- 別ターミナルで
npx portpreview 3000。 - HTTPS URL をプロバイダーの Webhook 設定に貼り付け。Stripe なら:Developers → Webhooks → Add endpoint。
- テストイベントをトリガー。リクエストはすべてのヘッダーを保ったまま route handler に届きます。
next dev --turbo を使っていても動きます。Turbopack はバンドラーを変えるだけで、リクエスト/レスポンスモデルは変えません。
Edge runtime:やめておく
Webhook route handler に export const runtime = 'edge' を設定すると、一部のプロバイダー SDK が使う Node 専用 crypto API へのアクセスを失います。Stripe の Node SDK は最近のバージョンでは edge で動きますが、任意のプロバイダーでの署名検証は当たり外れがあります。特定の理由がない限り、Webhook ルートは Node runtime に置いてください。
export const runtime = 'nodejs'; // explicit, safe default for webhooks
何度もハマる App Router の落とし穴
間違った型を返す
NextResponse.json(...) でなく new Response(JSON.stringify(...)) を返すと、content-type ヘッダーが設定されないことがあります。気にするプロバイダーもいます。NextResponse.json() を使うか、手動でヘッダーを設定してください。
ストリーミングと WebSocket
Webhook ハンドラーは短命な HTTP リクエストですが、WebSocket ルートも持つ Next.js アプリ(例:カスタムサーバー上の socket.io)をトンネルするなら、トンネルが WebSocket アップグレードを保持することを確認してください。ほとんどは保持します。ドキュメントを確認しましょう。
ボディを読むミドルウェア
ルートに、ロギングや認証のために request.text() を呼ぶ middleware.ts があると、Webhook ルートはボディを決して見られません。matcher で Webhook パスをスキップするか、必要なルートでのみボディを読んでください。
Vercel デプロイは救ってくれない
生ボディのパースに依存する Webhook バグは Vercel でも localhost でも同じように振る舞います。ローカルトンネルテストが署名検証を通れば、デプロイ版も通ります——サーバーレス側でメモリやタイムアウトの上限にぶつかる場合を除いて。その場合は症状が違います(401 でなく 5xx)。
Stripe の詳細は Stripe Webhook ローカルテストガイドを。署名の計算全般は 署名検証ガイドを。キャプチャ付きで Next.js に優しいトンネルを得るには PortPreview のウェイトリストへ。