所有文章
Next.jsApp Routerwebhook debugginglocal testing

在 localhost 上正确处理 Next.js Webhook

Next.js App Router 中 Webhook 处理器损坏的最大根源是原始 body。App Router 没问题。route handler 没问题。但获取未解析请求 body 的约定在 Pages Router 和 App Router 之间变了,而大多数 Stripe / GitHub / Shopify 集成都从旧世界复制片段。本文是新世界。

两行规则

在 App Router 的 route handler 中,用 await request.text() 获取原始 body。不要在签名验证前用 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 });
}

三行值得注意:request.text() 给你 Stripe 签名的原始字符串。App Router 中的 headers() 是异步的——注意 await。而且签名验证必须在任何 JSON.parse 等价操作之前发生。

要忘掉的 Pages Router 习惯

如果你从 Pages Router 迁移过来,你记得写过这个:

// pages/api/webhooks/stripe.ts (OLD)
export const config = { api: { bodyParser: false } };

那个 config 在 App Router 中不存在。body 按需从 Request 对象读取。你第二次读的东西是空的——request.json() 之后的 request.text() 什么都不返回。选一个,对于 webhook 永远选 text()

Next.js 开发的隧道设置

  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-only crypto API 的访问。Stripe 的 Node SDK 在近期版本中能在 edge 上工作,但与任意提供商的签名验证则时灵时不灵。除非有特定理由,把 webhook 路由留在 Node runtime。

export const runtime = 'nodejs'; // explicit, safe default for webhooks

我们反复踩的 App Router 坑

返回错误的类型

如果你返回 new Response(JSON.stringify(...)) 而不是 NextResponse.json(...),content-type 头可能没被设置。有些提供商在意。用 NextResponse.json() 或手动设置头。

流式和 websocket

Webhook 处理器是短命的 HTTP 请求,但如果你隧道一个同时有 WebSocket 路由的 Next.js 应用(例如在自定义服务器上用 socket.io),确保你的隧道保留 WebSocket 升级。大多数都会;查文档。

读取 body 的中间件

如果你在根目录有一个调用 request.text() 做日志或鉴权的 middleware.ts,你的 webhook 路由永远看不到 body。在你的 matcher 里跳过 webhook 路径,或只在需要的路由里读 body。

Vercel 部署救不了你

依赖原始 body 解析的 webhook bug 在 Vercel 和 localhost 上表现完全一致。一旦你的本地隧道测试通过签名验证,部署版本也会——除非你撞上 serverless 端的内存或超时限制,那种情况症状不同(5xx,不是 401)。

Stripe 细节见 Stripe webhook 本地测试指南。一般的签名数学见 签名验证指南加入 PortPreview 等候名单,获取一个对 Next.js 友好、带捕获的隧道。

常见问题

我如何在 Next.js App Router 的 webhook 里获取原始 body?
在 POST 处理器里调用 await request.text()。不要先调用 request.json()——body 被读取一次后,第二次调用 .text() 或 .json() 什么都不返回。
我在 Next.js App Router 里需要 bodyParser: false 吗?
不需要。那个 config 是 Pages Router 的模式,在 App Router 里没有等价物。body 按需从 Request 对象读取,需要验证签名时你就把它当原始文本读。
我能在 Next.js 的 edge runtime 上运行 webhook 路由吗?
可以,但签名验证 SDK 有时依赖 edge 上不可用的 Node crypto API。除非有特定理由切换,否则 webhook 用默认的 Node runtime。