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 开发的隧道设置
- 运行
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-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 友好、带捕获的隧道。