The single biggest source of broken webhook handlers in Next.js App Router is the raw body. The App Router is fine. Route handlers are fine. But the conventions for grabbing the unparsed request body changed between Pages Router and App Router, and most Stripe / GitHub / Shopify integrations copy snippets from the old world. This article is the new world.
The two-line rule
In an App Router route handler, get the raw body with await request.text(). Don't use request.json() before signature verification. Don't try to disable bodyParser the way you did in Pages Router — that flag doesn't exist anymore.
// 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 });
}
Three lines deserve attention: request.text() gives you the raw string Stripe signed. headers() in App Router is async — note the await. And the signature verification has to happen before any JSON.parse equivalent.
The Pages Router habit to unlearn
If you've migrated from Pages Router, you remember writing this:
// pages/api/webhooks/stripe.ts (OLD)
export const config = { api: { bodyParser: false } };
That config doesn't exist in App Router. The body is read on demand from the Request object. Anything you read second is empty — request.text() after request.json() returns nothing. Pick one, and for webhooks always pick text().
Tunnel setup for Next.js dev
- Run
next dev(ornpm run dev) — usually port 3000. - In another terminal,
npx portpreview 3000. - Paste the HTTPS URL into your provider's webhook configuration. For Stripe: Developers → Webhooks → Add endpoint.
- Trigger a test event. The request hits your route handler with all headers intact.
If you're using next dev --turbo, this still works. Turbopack changes the bundler, not the request/response model.
Edge runtime: don't
If you've set export const runtime = 'edge' on a webhook route handler, you'll lose access to Node-only crypto APIs that some provider SDKs use. The Stripe Node SDK works on edge in recent versions, but signature verification with arbitrary providers is hit and miss. Keep webhook routes on the Node runtime unless you have a specific reason.
export const runtime = 'nodejs'; // explicit, safe default for webhooks
App Router gotchas we keep hitting
Returning the wrong type
If you return new Response(JSON.stringify(...)) instead of NextResponse.json(...), the content-type header may not be set. Some providers care. Use NextResponse.json() or set the header manually.
Streaming and websockets
Webhook handlers are short-lived HTTP requests, but if you're tunneling a Next.js app that also has WebSocket routes (for example with socket.io on a custom server), make sure your tunnel preserves WebSocket upgrades. Most do; check the docs.
Middleware reading the body
If you have middleware.ts at the root that calls request.text() for logging or auth, your webhook route never sees the body. Skip the webhook path in your matcher or read bodies only in routes that need them.
Vercel deploys won't save you
Webhook bugs that depend on raw body parsing behave identically on Vercel and on localhost. Once your local tunnel test passes signature verification, the deployed version will too — unless you're hitting a memory or timeout limit on the serverless side, in which case the symptom is different (5xx, not 401).
For Stripe specifics see the Stripe webhook local testing guide. For the signature math in general, the signature verification guide. Join the PortPreview waitlist to get a Next.js-friendly tunnel with capture.