Die mit Abstand größte Quelle kaputter Webhook-Handler im Next.js App Router ist der Raw Body. Der App Router ist in Ordnung. Route Handler sind in Ordnung. Aber die Konventionen zum Erfassen des ungeparsten Request-Bodys haben sich zwischen Pages Router und App Router geändert, und die meisten Stripe-/GitHub-/Shopify-Integrationen kopieren Snippets aus der alten Welt. Dieser Artikel ist die neue Welt.
Die Zwei-Zeilen-Regel
In einem App-Router-Route-Handler holst du den Raw Body mit await request.text(). Nutze nicht request.json() vor der Signaturprüfung. Versuche nicht, bodyParser so wie im Pages Router zu deaktivieren — dieses Flag existiert nicht mehr.
// 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 });
}
Drei Zeilen verdienen Aufmerksamkeit: request.text() gibt dir den rohen String, den Stripe signiert hat. headers() im App Router ist async — beachte das await. Und die Signaturprüfung muss vor jedem JSON.parse-Äquivalent passieren.
Die Pages-Router-Gewohnheit, die du verlernen musst
Wenn du vom Pages Router migriert bist, erinnerst du dich, das hier geschrieben zu haben:
// pages/api/webhooks/stripe.ts (OLD)
export const config = { api: { bodyParser: false } };
Diese Config existiert im App Router nicht. Der Body wird bei Bedarf aus dem Request-Objekt gelesen. Was du als Zweites liest, ist leer — request.text() nach request.json() gibt nichts zurück. Wähle eines, und für Webhooks immer text().
Tunnel-Setup für Next.js-Dev
- Führe
next dev(odernpm run dev) aus — meist Port 3000. - In einem anderen Terminal
npx portpreview 3000. - Füge die HTTPS-URL in die Webhook-Konfiguration deines Anbieters ein. Für Stripe: Developers → Webhooks → Add endpoint.
- Löse ein Testevent aus. Der Request trifft deinen Route Handler mit allen Headern intakt.
Wenn du next dev --turbo nutzt, funktioniert das weiterhin. Turbopack ändert den Bundler, nicht das Request/Response-Modell.
Edge Runtime: lieber nicht
Wenn du export const runtime = 'edge' auf einem Webhook-Route-Handler gesetzt hast, verlierst du Zugriff auf Node-only-Crypto-APIs, die manche Anbieter-SDKs nutzen. Das Stripe-Node-SDK funktioniert in neueren Versionen auf Edge, aber Signaturprüfung mit beliebigen Anbietern ist mal so, mal so. Halte Webhook-Routen auf der Node-Runtime, außer du hast einen bestimmten Grund.
export const runtime = 'nodejs'; // explicit, safe default for webhooks
App-Router-Stolperfallen, in die wir immer wieder tappen
Den falschen Typ zurückgeben
Wenn du new Response(JSON.stringify(...)) statt NextResponse.json(...) zurückgibst, wird der content-type-Header womöglich nicht gesetzt. Manchen Anbietern ist das wichtig. Nutze NextResponse.json() oder setze den Header manuell.
Streaming und WebSockets
Webhook-Handler sind kurzlebige HTTP-Requests, aber wenn du eine Next.js-App tunnelst, die auch WebSocket-Routen hat (zum Beispiel mit socket.io auf einem Custom Server), stelle sicher, dass dein Tunnel WebSocket-Upgrades erhält. Die meisten tun das; prüfe die Docs.
Middleware, die den Body liest
Wenn du eine middleware.ts im Root hast, die request.text() fürs Logging oder Auth aufruft, sieht deine Webhook-Route den Body nie. Überspringe den Webhook-Pfad in deinem Matcher oder lies Bodys nur in Routen, die sie brauchen.
Vercel-Deploys retten dich nicht
Webhook-Bugs, die vom Raw-Body-Parsing abhängen, verhalten sich auf Vercel und localhost identisch. Sobald dein lokaler Tunnel-Test die Signaturprüfung besteht, tut es die deployte Version auch — außer du stößt auf ein Memory- oder Timeout-Limit auf der Serverless-Seite, dann ist das Symptom anders (5xx, nicht 401).
Für Stripe-Spezifika siehe den Leitfaden zum lokalen Stripe-Webhook-Testing. Für die Signatur-Mathematik allgemein den Signaturprüfungs-Leitfaden. Tritt der PortPreview-Warteliste bei, um einen Next.js-freundlichen Tunnel mit Capture zu bekommen.