Stripe en Next.js: Checkout, webhooks y acceso Pro

Stripe en Next.js: Checkout, webhooks y acceso Pro

Una visita a /dashboard?checkout=success no demuestra que un pago se haya completado: cualquiera puede escribir esa URL. En una integración de suscripciones con Stripe, el cambio de plan debe llegar al servidor mediante un webhook verificado; después, la app consulta su base de datos para decidir qué funciones puede usar la persona.

El flujo de este ejemplo usa TypeScript, Route Handlers de Next.js, stripe-node, Prisma con Postgres y una función de autenticación del lado servidor llamada auth(). El SDK oficial de Stripe para Node.js se instala con npm y necesita una clave secreta; Next.js permite recibir estas notificaciones con Route Handlers públicos. El código supone que ya existen @/auth y @/lib/db en el proyecto.

flowchart TD
A["Usuario inicia Checkout"] --> B["Servidor crea sesión"]
B --> C["Stripe completa el flujo"]
C --> D["Webhook verifica evento"]
D --> E["Base de datos sincroniza plan"]
E --> F["Servidor comprueba acceso"]

Preparar Stripe y el modelo de datos

Instala el SDK y el paquete server-only para marcar el módulo que contiene la clave secreta. En Stripe, crea un producto con un precio recurrente y guarda su identificador price_... en el entorno del servidor. El navegador no elegirá el precio: el Route Handler lo obtiene de una variable de entorno.

npm install stripe server-only
// lib/stripe.ts
import "server-only";
import Stripe from "stripe";

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

El ejemplo registra en el usuario el cliente de Stripe, la suscripción, el precio y su estado. Una segunda tabla guarda los identificadores de eventos ya recibidos, con una clave única que permite detectar entregas repetidas.

model User {
  id                    String    @id @default(cuid())
  email                 String    @unique
  stripeCustomerId      String?   @unique
  stripeSubscriptionId  String?   @unique
  stripePriceId         String?
  subscriptionStatus    String?
}

model StripeEvent {
  id        String   @id
  type      String
  createdAt DateTime @default(now())
}

Configura en el entorno del servidor STRIPE_SECRET_KEY, STRIPE_PRICE_PRO y APP_URL. El secreto de firma del webhook se añadirá cuando se configure el endpoint. El precio lo decide el servidor: no aceptes del cliente un importe o identificador de precio que determine cuánto se cobra.

Crear la sesión de Checkout

El navegador solo necesita llamar a este endpoint y redirigir a la URL devuelta. El handler comprueba la sesión autenticada, reutiliza el cliente de Stripe asociado al usuario o crea uno nuevo, y construye una sesión en modo suscripción. Añadir el ID del usuario a los metadatos de la suscripción permite relacionar después los eventos con la cuenta.

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

export async function POST() {
  const session = await auth();
  const userId = session?.user?.id;

  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
  let customerId = user.stripeCustomerId;

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email,
      metadata: { userId: user.id },
    });
    customerId = customer.id;
    await db.user.update({
      where: { id: user.id },
      data: { stripeCustomerId: customerId },
    });
  }

  const checkout = await stripe.checkout.sessions.create({
    mode: "subscription",
    customer: customerId,
    client_reference_id: user.id,
    line_items: [{ price: process.env.STRIPE_PRICE_PRO!, quantity: 1 }],
    subscription_data: { metadata: { userId: user.id } },
    success_url: `${process.env.APP_URL}/dashboard?checkout=success`,
    cancel_url: `${process.env.APP_URL}/pricing`,
  });

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

En la interfaz, un botón puede enviar un POST a /api/checkout y navegar a url. La pantalla de éxito sirve para mostrar una confirmación de navegación, no para conceder acceso: esa autorización depende del estado que actualice el webhook.

Verificar eventos y sincronizar la suscripción

Stripe firma los bytes exactos del cuerpo de la petición. Lee el cuerpo con req.text() y pásalo a constructEvent antes de tratar el contenido como confiable. Verifica el cuerpo sin parsearlo: leerlo como JSON y serializarlo de nuevo puede cambiar los bytes y hacer fallar la firma. Esta ruta usa el runtime de Node.js y responde con error si la firma es inválida.

// app/api/stripe/webhook/route.ts
import { Prisma } from "@prisma/client";
import Stripe from "stripe";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";
import { syncSubscription } from "@/lib/billing";

export const runtime = "nodejs";

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature");
  if (!signature) return new Response("Missing signature", { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  try {
    await db.stripeEvent.create({ data: { id: event.id, type: event.type } });
  } catch (error) {
    if (
      error instanceof Prisma.PrismaClientKnownRequestError &&
      error.code === "P2002"
    ) {
      return new Response("Already processed", { status: 200 });
    }
    throw error;
  }

  try {
    if (event.type === "checkout.session.completed") {
      const checkout = event.data.object as Stripe.Checkout.Session;
      if (checkout.mode === "subscription" && checkout.subscription) {
        const subscriptionId = typeof checkout.subscription === "string"
          ? checkout.subscription
          : checkout.subscription.id;
        await syncSubscription(subscriptionId);
      }
    }

    if (
      event.type === "customer.subscription.created" ||
      event.type === "customer.subscription.updated" ||
      event.type === "customer.subscription.deleted"
    ) {
      const subscription = event.data.object as Stripe.Subscription;
      await syncSubscription(subscription.id);
    }
  } catch {
    await db.stripeEvent.delete({ where: { id: event.id } }).catch(() => {});
    return new Response("Handler failed", { status: 500 });
  }

  return new Response("ok", { status: 200 });
}

La tabla de eventos evita procesar dos veces el mismo ID. Si la sincronización falla, el handler borra el registro de deduplicación y responde con un error para que Stripe pueda reintentar. Para sincronizar, consulta la suscripción actual en Stripe en vez de reconstruir el estado aplicando eventos uno a uno: pueden llegar fuera de orden.

// lib/billing.ts
import "server-only";
import { db } from "@/lib/db";
import { stripe } from "@/lib/stripe";

export async function syncSubscription(subscriptionId: string) {
  const subscription = await stripe.subscriptions.retrieve(subscriptionId);
  const item = subscription.items.data[0];
  const customerId = typeof subscription.customer === "string"
    ? subscription.customer
    : subscription.customer.id;

  await db.user.update({
    where: { stripeCustomerId: customerId },
    data: {
      stripeSubscriptionId: subscription.id,
      stripePriceId: item?.price.id ?? null,
      subscriptionStatus: subscription.status,
    },
  });
}

Esta sincronización guarda el estado y el precio del primer elemento de la suscripción. Los eventos de Checkout y de suscripción mantienen esos datos al día cuando se crea, actualiza o elimina una suscripción. Los eventos repetidos no duplican cambios porque la tabla usa el ID del evento como clave primaria.

Restringir funciones Pro y añadir el portal

Centraliza la regla de acceso. Aquí solo se consideran Pro las suscripciones con estado active o trialing y cuyo precio coincide con el configurado. El tratamiento de past_due queda fuera de esa regla; si la aplicación debe conceder un periodo de gracia, el criterio se cambia aquí, no en componentes aislados.

// lib/plan.ts
const proPrices = new Set([process.env.STRIPE_PRICE_PRO!]);

type PlanUser = {
  subscriptionStatus: string | null;
  stripePriceId: string | null;
};

export function isPro(user: PlanUser) {
  return (
    (user.subscriptionStatus === "active" ||
      user.subscriptionStatus === "trialing") &&
    !!user.stripePriceId &&
    proPrices.has(user.stripePriceId)
  );
}

Haz la comprobación en el servidor, junto al recurso protegido. Ocultar un botón en un componente cliente no bloquea una petición directa a la página o a una API.

// app/dashboard/reports/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { isPro } from "@/lib/plan";

export default async function ReportsPage() {
  const session = await auth();
  const userId = session?.user?.id;
  if (!userId) redirect("/login");

  const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
  if (!isPro(user)) redirect("/pricing");

  return <main>Informes para {user.email}</main>;
}

Aplica la misma condición a las Server Actions y los Route Handlers que realicen trabajo de pago. El middleware puede ayudar con redirecciones, pero no debe ser la única comprobación de acceso: verifica permisos donde se lee o modifica el recurso.

Para que cada cliente gestione su suscripción, crea una sesión del portal desde el servidor. Activa las funciones necesarias en la configuración del Customer Portal de Stripe; las modificaciones del cliente volverán a sincronizarse mediante los eventos que ya procesa el webhook.

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

export async function POST() {
  const session = await auth();
  const userId = session?.user?.id;
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUniqueOrThrow({ where: { id: userId } });
  if (!user.stripeCustomerId) {
    return NextResponse.json({ error: "No customer" }, { status: 400 });
  }

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

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

Prueba local y errores habituales

Para probar los webhooks en local, inicia sesión con Stripe CLI y reenvía los eventos a la ruta. El CLI proporciona un secreto de firma propio: usa ese valor localmente, no el del endpoint de pruebas o producción. Cada endpoint tiene su secreto y las variables de entorno de Vercel para Preview y Production deben mantenerse separadas.

stripe login
stripe listen --forward-to localhost:3000/api/stripe/webhook

# En otra terminal
stripe trigger customer.subscription.updated

Un recorrido de prueba puede completar Checkout en modo de pruebas, comprobar que cambió la fila del usuario, abrir el portal y cancelar. También conviene repetir un evento desde el Dashboard y comprobar que su ID no se procesa por segunda vez. Stripe recomienda el cuerpo sin parsear para verificar firmas; Next.js documenta que el cuerpo de una petición solo puede leerse una vez.

Si el webhook no llega, revisa que la autenticación global no redirija esa ruta a la página de inicio de sesión y que no haya redirecciones de URL: Stripe no sigue redirecciones de webhook. Si la firma falla, comprueba que el secreto corresponda al entorno. Y si el acceso se concede desde la URL de éxito o solo se oculta en el cliente, traslada la decisión al webhook y a la comprobación del servidor.

Con estos componentes, Stripe recibe la solicitud de suscripción, el webhook confirma y sincroniza el estado, y Next.js aplica el permiso al servir la función protegida. La pantalla de éxito informa al usuario; la base de datos y la comprobación del servidor determinan el acceso.

Fuentes: DEV Community, Vercel Academy, GitHub, Next.js