すべての記事
Next.jsApp Routerwebhook debugginglocal testing

localhost で Next.js Webhook を正しく

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 のトンネル設定

  1. next dev(または npm run dev)を実行——通常ポート 3000。
  2. 別ターミナルで npx portpreview 3000
  3. HTTPS URL をプロバイダーの Webhook 設定に貼り付け。Stripe なら:Developers → Webhooks → Add endpoint
  4. テストイベントをトリガー。リクエストはすべてのヘッダーを保ったまま 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 のウェイトリストへ。

よくある質問

Next.js App Router の Webhook で生ボディをどう取得する?
POST ハンドラー内で await request.text() を呼びます。先に request.json() を呼ばないこと——ボディが一度読まれると、.text() や .json() の 2 回目の呼び出しは何も返しません。
Next.js App Router で bodyParser: false は必要?
いいえ。その config は Pages Router のパターンで、App Router に相当物はありません。ボディは Request オブジェクトからオンデマンドで読まれ、署名を検証する必要があるときは生テキストとして読みます。
Webhook ルートを Next.js の edge runtime で動かせる?
できますが、署名検証 SDK は edge で使えない Node crypto API に依存することがあります。切り替える特定の理由がない限り、Webhook はデフォルトの Node runtime のままにしてください。