Todos los artículos
Next.jsApp Routerwebhook debugginglocal testing

Webhooks de Next.js en localhost, bien hechos

La mayor fuente de handlers de webhook rotos en Next.js App Router es el cuerpo crudo. El App Router está bien. Los route handlers están bien. Pero las convenciones para tomar el cuerpo sin parsear cambiaron entre Pages Router y App Router, y la mayoría de las integraciones de Stripe / GitHub / Shopify copian fragmentos del mundo viejo. Este artículo es el mundo nuevo.

La regla de dos líneas

En un route handler del App Router, obtén el cuerpo crudo con await request.text(). No uses request.json() antes de la verificación de firma. No intentes desactivar bodyParser como hacías en Pages Router — ese flag ya no existe.

// 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 });
}

Tres líneas merecen atención: request.text() te da la cadena cruda que Stripe firmó. headers() en App Router es async — nota el await. Y la verificación de firma tiene que ocurrir antes de cualquier equivalente a JSON.parse.

El hábito de Pages Router que debes desaprender

Si migraste desde Pages Router, recordarás escribir esto:

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

Esa config no existe en App Router. El cuerpo se lee bajo demanda del objeto Request. Lo que leas en segundo lugar está vacío — request.text() después de request.json() no devuelve nada. Elige uno, y para webhooks siempre elige text().

Configuración del túnel para el dev de Next.js

  1. Ejecuta next dev (o npm run dev) — normalmente puerto 3000.
  2. En otra terminal, npx portpreview 3000.
  3. Pega la URL HTTPS en la configuración de webhook de tu proveedor. Para Stripe: Developers → Webhooks → Add endpoint.
  4. Dispara un evento de prueba. La petición llega a tu route handler con todos los headers intactos.

Si usas next dev --turbo, esto sigue funcionando. Turbopack cambia el bundler, no el modelo request/response.

Edge runtime: no

Si has puesto export const runtime = 'edge' en un route handler de webhook, perderás acceso a APIs de crypto solo-Node que usan algunos SDKs de proveedores. El SDK de Node de Stripe funciona en edge en versiones recientes, pero la verificación de firma con proveedores arbitrarios es incierta. Mantén las rutas de webhook en el runtime Node salvo que tengas una razón específica.

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

Trampas del App Router que seguimos encontrando

Devolver el tipo equivocado

Si devuelves new Response(JSON.stringify(...)) en vez de NextResponse.json(...), el header content-type puede no quedar establecido. A algunos proveedores les importa. Usa NextResponse.json() o establece el header manualmente.

Streaming y websockets

Los handlers de webhook son peticiones HTTP de vida corta, pero si tunelizas una app de Next.js que también tiene rutas WebSocket (por ejemplo con socket.io en un servidor custom), asegúrate de que tu túnel preserve los upgrades de WebSocket. La mayoría lo hace; revisa la documentación.

Middleware que lee el cuerpo

Si tienes un middleware.ts en la raíz que llama a request.text() para logging o auth, tu ruta de webhook nunca ve el cuerpo. Salta la ruta de webhook en tu matcher o lee cuerpos solo en las rutas que los necesitan.

Los deploys en Vercel no te salvarán

Los bugs de webhook que dependen del parseo del cuerpo crudo se comportan idénticamente en Vercel y en localhost. Una vez que tu prueba local en túnel pasa la verificación de firma, la versión desplegada también lo hará — a menos que choques con un límite de memoria o timeout del lado serverless, en cuyo caso el síntoma es distinto (5xx, no 401).

Para detalles de Stripe mira la guía de pruebas locales de webhooks de Stripe. Para las matemáticas de firma en general, la guía de verificación de firma. Únete a la lista de espera de PortPreview para obtener un túnel amigable con Next.js y captura.

Preguntas frecuentes

¿Cómo obtengo el cuerpo crudo en un webhook de Next.js App Router?
Llama a await request.text() dentro del handler POST. No llames a request.json() primero — una vez leído el cuerpo, llamar a .text() o .json() por segunda vez no devuelve nada.
¿Necesito bodyParser: false en Next.js App Router?
No. Esa config era un patrón de Pages Router y no tiene equivalente en App Router. El cuerpo se lee bajo demanda del objeto Request, y lo lees como texto crudo cuando necesitas verificar una firma.
¿Puedo correr una ruta de webhook en el edge runtime de Next.js?
Puedes, pero los SDKs de verificación de firma a veces dependen de APIs de crypto de Node que no están disponibles en edge. Quédate con el runtime Node por defecto para webhooks salvo que tengas una razón específica para cambiar.