Tutorial · Pagos

Integrar Stripe en Next.js: suscripciones paso a paso

IndiePack IndiePack
· · 12 min lectura

Integrar pagos recurrentes con Stripe en Next.js parece sencillo hasta que llegas a los webhooks — y ahí es donde el 90 % de los tutoriales en inglés se quedan cortos o dejan agujeros de seguridad. Esta guía, en castellano y con App Router, te lleva de cero a una suscripción funcionando bien hecha: Checkout, webhooks verificados y Customer Portal.

✦ Lo que vas a montar

Un flujo completo de suscripción: el usuario pulsa «Suscribirme» → va a Stripe Checkout → paga → un webhook actualiza tu base de datos → el usuario gestiona o cancela desde el Customer Portal de Stripe. Sin construir formularios de tarjeta tú mismo.

Índice

  1. Requisitos previos
  2. Crear productos y precios en Stripe
  3. Instalar y configurar el SDK
  4. Sesión de Checkout (App Router)
  5. Webhooks: la parte crítica
  6. Customer Portal: gestión y bajas
  7. Los 5 errores más comunes
  8. ¿Y el IVA?
  9. Preguntas frecuentes
  10. Conclusión

Requisitos previos

Crear productos y precios

En el dashboard de Stripe → Productos, crea uno (p. ej. «Plan Pro»), y dentro un precio recurrente (mensual o anual). Copia el price_id (algo como price_1Q...). Ese ID es lo que pasas al crear el Checkout — no el importe a mano.

Instalar y configurar el SDK

npm install stripe

# .env.local
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000

Centraliza el cliente de Stripe en un único archivo para reutilizarlo:

// lib/stripe.ts
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: "2025-05-28.basil",
});

Sesión de Checkout (App Router)

Crea una Route Handler. Cuando el usuario pulsa el botón, llamas a este endpoint y rediriges a la URL que devuelve Stripe:

// app/api/checkout/route.ts
import { stripe } from "@/lib/stripe";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { priceId, userId } = await req.json();

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    // Vincula la sesión a TU usuario para reconocerlo en el webhook:
    client_reference_id: userId,
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?ok=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/precios`,
  });

  return NextResponse.json({ url: session.url });
}

✦ Clave

El client_reference_id (o metadata) es cómo unes la suscripción de Stripe con el usuario de tu base de datos. Sin esto, cuando llegue el webhook no sabrás a quién dar acceso.

Webhooks: la parte crítica

Nunca des acceso premium en la página de éxito del navegador. Esa redirección puede falsearse o no llegar. La verdad del pago vive en el webhook.

Dos detalles que rompen a todo el mundo en App Router: necesitas el cuerpo crudo (raw body) para verificar la firma, y debes leerlo con req.text(), no req.json():

// app/api/webhooks/stripe/route.ts
import { stripe } from "@/lib/stripe";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const body = await req.text();              // raw, sin parsear
  const sig = req.headers.get("stripe-signature")!;

  let event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new NextResponse("Firma inválida", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed": {
      const s = event.data.object;
      // s.client_reference_id = tu userId → activa el plan en tu BBDD
      break;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      // actualiza estado: activa / cancelada / morosa
      break;
    }
  }

  return NextResponse.json({ received: true });
}

En local, reenvía los eventos con la CLI:

stripe listen --forward-to localhost:3000/api/webhooks/stripe

La CLI te imprime el whsec_... que va en STRIPE_WEBHOOK_SECRET. En producción, el secreto lo obtienes al registrar el endpoint en el dashboard.

Todo esto ya montado.

IndiePack trae Stripe (y LemonSqueezy) integrado con webhooks verificados, Customer Portal y la BBDD conectada. En castellano.

Ver el stack →

Customer Portal: gestión y bajas

No construyas tú la pantalla de «cambiar de plan / cancelar». Stripe te la da hecha con el Billing Portal. Generas una sesión y rediriges:

// app/api/portal/route.ts
import { stripe } from "@/lib/stripe";
import { NextResponse } from "next/server";

export async function POST(req: Request) {
  const { customerId } = await req.json();    // guardado en tu BBDD

  const portal = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
  });

  return NextResponse.json({ url: portal.url });
}

Guarda el stripe_customer_id en tu base de datos en cuanto se cree la suscripción. Lo necesitas tanto para el portal como para reconciliar webhooks.

Los 5 errores más comunes

  1. Dar acceso desde la página de éxito. Hazlo solo desde el webhook verificado.
  2. Usar req.json() en el webhook. Rompe la verificación de firma. Usa req.text().
  3. No guardar customer_id ni subscription_id. Sin ellos no puedes gestionar bajas ni renovaciones.
  4. Ignorar customer.subscription.deleted. Si no lo escuchas, sigues dando premium a quien ya canceló.
  5. Probar solo el «happy path». Usa las tarjetas de test de fallo de Stripe (pagos rechazados, 3D Secure) antes de lanzar.

¿Y el IVA?

Stripe es una pasarela, no un merchant of record: el IVA lo gestionas tú (o activas Stripe Tax). Si vendes a particulares por toda la UE y no quieres pelearte con el IVA por país, quizá te interese más LemonSqueezy. Lo comparamos en LemonSqueezy vs Stripe, y la parte fiscal española la detallamos en cómo facturar tu SaaS como autónomo.

Preguntas frecuentes

¿Funciona con el App Router?

Sí. Usas Route Handlers para Checkout y webhooks. El único detalle es leer el raw body con req.text() en el webhook.

¿Necesito un formulario de tarjeta propio?

No. Con Stripe Checkout, Stripe aloja el formulario de pago (PCI-compliant). Tú solo rediriges.

¿Cómo pruebo los pagos sin dinero real?

En modo test, usa la tarjeta 4242 4242 4242 4242 con cualquier fecha futura y CVC.

Conclusión

Integrar Stripe con suscripciones en Next.js son cuatro piezas: Checkout para cobrar, webhooks verificados para saber la verdad del pago, tu BBDD como fuente del estado, y el Customer Portal para que el usuario se gestione solo. Hazlo bien una vez y lo reutilizas en cada proyecto.

O sáltate el montaje: IndiePack trae todo este flujo ya implementado y probado, con webhooks, portal y base de datos conectada — en castellano y por un pago único.

Pagos resueltos desde el minuto uno.

Stripe y LemonSqueezy integrados, webhooks verificados, Customer Portal. Copia, configura y a vender.

Conseguir IndiePack — 250€ →