Saltar al contenido principal
Volver al blog

Cómo integrar Stripe en Next.js 15 con App Router

Ray MartínRay Martín
12 min de lectura
Cómo integrar Stripe en Next.js 15 con App Router

Requisitos previos

Antes de empezar, asegúrate de tener:

  • Un proyecto Next.js 15 con App Router configurado.
  • Una cuenta en Stripe (la cuenta gratuita incluye modo test completo).
  • Node.js 18 o superior.
  • Conocimientos básicos de React, TypeScript y API Routes en Next.js.

Esta guía asume que trabajas con TypeScript en modo estricto y que tu proyecto usa la estructura de App Router (/app).

Instalación de dependencias

Necesitamos dos paquetes: stripe para el servidor y @stripe/stripe-js para el cliente.

bash
# SDK del servidor (Node.js)
npm install stripe

# SDK del cliente (navegador)
npm install @stripe/stripe-js

Configuración de variables de entorno

Crea o actualiza tu archivo .env.local con las claves de Stripe. Puedes encontrar tus claves de test en el Dashboard de Stripe en la sección Developers > API Keys.

bash
# .env.local

# Clave secreta del servidor (empieza con sk_test_)
STRIPE_SECRET_KEY=sk_test_51ABC...

# Clave pública del cliente (empieza con pk_test_)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51ABC...

# Secreto del webhook (empieza con whsec_)
STRIPE_WEBHOOK_SECRET=whsec_ABC...

Añade los tipos en tu archivo environment.d.ts:

typescript
declare namespace NodeJS {
  interface ProcessEnv {
    STRIPE_SECRET_KEY: string;
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: string;
    STRIPE_WEBHOOK_SECRET: string;
  }
}

Importante: Solo las variables con prefijo NEXT_PUBLIC_ son accesibles desde el navegador. La clave secreta STRIPE_SECRET_KEY nunca debe exponerse al cliente.

Crear productos en Stripe Dashboard

Antes de escribir código, configura tus productos en el Dashboard de Stripe:

  1. Ve a Products en el menú lateral.
  2. Haz clic en Add product.
  3. Configura nombre, descripción e imagen.
  4. Añade un precio (puntual o recurrente para suscripciones).
  5. Guarda y copia el Price ID (empieza con price_).

Los Price IDs son los que usaremos en nuestro código para crear sesiones de checkout.

Configurar Stripe en el servidor

Crea un archivo de utilidad para inicializar la instancia de Stripe en el servidor:

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

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-12-18.acacia",
  typescript: true,
});

Server-side: crear sesión de checkout

Crea un Route Handler en la carpeta /app/api que genere una sesión de Stripe Checkout:

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

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { priceId, quantity = 1 } = body;

    if (!priceId) {
      return NextResponse.json(
        { error: "El priceId es obligatorio" },
        { status: 400 }
      );
    }

    const session = await stripe.checkout.sessions.create({
      mode: "payment",
      payment_method_types: ["card"],
      line_items: [
        {
          price: priceId,
          quantity,
        },
      ],
      success_url: `${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${request.nextUrl.origin}/checkout/cancel`,
      metadata: {
        priceId,
      },
    });

    return NextResponse.json({ url: session.url });
  } catch (error) {
    console.error("Error al crear sesión de checkout:", error);

    if (error instanceof Error) {
      return NextResponse.json(
        { error: error.message },
        { status: 500 }
      );
    }

    return NextResponse.json(
      { error: "Error interno del servidor" },
      { status: 500 }
    );
  }
}

Client-side: botón de checkout con redirect

Crea un componente que llame al endpoint y redirija al usuario a la página de pago de Stripe:

typescript
// components/CheckoutButton.tsx
"use client";

import { useState } from "react";

interface CheckoutButtonProps {
  priceId: string;
  label?: string;
}

export default function CheckoutButton({
  priceId,
  label = "Comprar ahora",
}: CheckoutButtonProps) {
  const [isLoading, setIsLoading] = useState(false);

  const handleCheckout = async () => {
    setIsLoading(true);

    try {
      const response = await fetch("/api/checkout", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ priceId }),
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Error al crear la sesión");
      }

      // Redirigir a Stripe Checkout
      if (data.url) {
        window.location.href = data.url;
      }
    } catch (error) {
      console.error("Error en checkout:", error);
      alert("Hubo un error al procesar el pago. Inténtalo de nuevo.");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button
      onClick={handleCheckout}
      disabled={isLoading}
      className="rounded-lg bg-indigo-600 px-6 py-3 text-white
                 font-semibold transition-colors hover:bg-indigo-700
                 disabled:cursor-not-allowed disabled:opacity-50"
      aria-label={label}
    >
      {isLoading ? "Procesando..." : label}
    </button>
  );
}

Uso del componente

typescript
// app/pricing/page.tsx
import CheckoutButton from "@/components/CheckoutButton";

export default function PricingPage() {
  return (
    <main className="container mx-auto py-16">
      <h1 className="text-3xl font-bold mb-8">Planes</h1>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
        <div className="rounded-xl border p-8">
          <h2 className="text-xl font-semibold">Plan Básico</h2>
          <p className="mt-2 text-gray-600">Ideal para empezar</p>
          <p className="mt-4 text-3xl font-bold">29/mes</p>
          <CheckoutButton
            priceId="price_1ABC123def456"
            label="Elegir Plan Básico"
          />
        </div>

        <div className="rounded-xl border p-8">
          <h2 className="text-xl font-semibold">Plan Pro</h2>
          <p className="mt-2 text-gray-600">Para profesionales</p>
          <p className="mt-4 text-3xl font-bold">79/mes</p>
          <CheckoutButton
            priceId="price_1DEF789ghi012"
            label="Elegir Plan Pro"
          />
        </div>
      </div>
    </main>
  );
}

Webhooks: verificar eventos con endpointSecret

Los webhooks son esenciales para confirmar pagos de forma fiable. Stripe envía eventos a tu servidor cuando ocurren acciones (pago completado, fallido, reembolso, etc.). Nunca confíes solo en la redirección del cliente para confirmar un pago.

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

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get("stripe-signature");

  if (!signature) {
    return NextResponse.json(
      { error: "Falta la firma de Stripe" },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (error) {
    console.error("Error al verificar webhook:", error);
    return NextResponse.json(
      { error: "Firma del webhook inválida" },
      { status: 400 }
    );
  }

  // Manejar los diferentes tipos de eventos
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleSuccessfulPayment(session);
      break;
    }

    case "payment_intent.payment_failed": {
      const paymentIntent = event.data
        .object as Stripe.PaymentIntent;
      await handleFailedPayment(paymentIntent);
      break;
    }

    case "customer.subscription.deleted": {
      const subscription = event.data
        .object as Stripe.Subscription;
      await handleCancelledSubscription(subscription);
      break;
    }

    default:
      console.log(`Evento no manejado: ${event.type}`);
  }

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

async function handleSuccessfulPayment(
  session: Stripe.Checkout.Session
) {
  const customerEmail = session.customer_details?.email;
  const sessionId = session.id;

  console.log(
    `Pago exitoso: ${sessionId} - Cliente: ${customerEmail}`
  );

  // Aquí implementarías la lógica de negocio:
  // - Activar la cuenta del usuario
  // - Enviar email de confirmación
  // - Registrar en base de datos
  // - Otorgar acceso al producto/servicio
}

async function handleFailedPayment(
  paymentIntent: Stripe.PaymentIntent
) {
  console.error(
    `Pago fallido: ${paymentIntent.id} - ${paymentIntent.last_payment_error?.message}`
  );

  // Notificar al usuario o al equipo
}

async function handleCancelledSubscription(
  subscription: Stripe.Subscription
) {
  console.log(`Suscripción cancelada: ${subscription.id}`);

  // Revocar acceso al servicio
}

Configuración importante del webhook

El Route Handler del webhook necesita recibir el body crudo (sin parsear) para verificar la firma. En Next.js 15 con App Router, request.text() devuelve el body como string, que es exactamente lo que necesitamos.

Manejar pagos exitosos y fallidos

Crea páginas para manejar la redirección después del pago:

Página de éxito

typescript
// app/checkout/success/page.tsx
import { stripe } from "@/lib/stripe";

interface SuccessPageProps {
  searchParams: Promise<{ session_id?: string }>;
}

export default async function SuccessPage({
  searchParams,
}: SuccessPageProps) {
  const { session_id } = await searchParams;

  if (!session_id) {
    return (
      <main className="container mx-auto py-16 text-center">
        <h1 className="text-2xl font-bold text-red-600">
          Sesión no encontrada
        </h1>
      </main>
    );
  }

  const session = await stripe.checkout.sessions.retrieve(
    session_id
  );

  return (
    <main className="container mx-auto py-16 text-center">
      <h1 className="text-3xl font-bold text-green-600">
        ¡Pago exitoso!
      </h1>
      <p className="mt-4 text-gray-600">
        Gracias por tu compra, {session.customer_details?.name}.
      </p>
      <p className="mt-2 text-sm text-gray-500">
        Hemos enviado la confirmación a{" "}
        {session.customer_details?.email}.
      </p>
    </main>
  );
}

Página de cancelación

typescript
// app/checkout/cancel/page.tsx
import Link from "next/link";

export default function CancelPage() {
  return (
    <main className="container mx-auto py-16 text-center">
      <h1 className="text-2xl font-bold">Pago cancelado</h1>
      <p className="mt-4 text-gray-600">
        Tu pago ha sido cancelado. No se ha realizado ningún cargo.
      </p>
      <Link
        href="/pricing"
        className="mt-6 inline-block rounded-lg bg-indigo-600
                   px-6 py-3 text-white hover:bg-indigo-700"
      >
        Volver a los planes
      </Link>
    </main>
  );
}

Modo test vs producción

Stripe proporciona un entorno de test completo con tarjetas de prueba:

  • Pago exitoso: 4242 4242 4242 4242
  • Pago rechazado: 4000 0000 0000 0002
  • Requiere autenticación 3D Secure: 4000 0025 0000 3155
  • Fondos insuficientes: 4000 0000 0000 9995

Para las pruebas, usa cualquier fecha futura como expiración, cualquier CVC de 3 dígitos y cualquier código postal.

Probar webhooks en local

Para probar webhooks durante el desarrollo, usa la CLI de Stripe:

bash
# Instalar Stripe CLI
brew install stripe/stripe-cli/stripe

# Iniciar sesión
stripe login

# Redirigir eventos a tu servidor local
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# En otra terminal, disparar un evento de prueba
stripe trigger checkout.session.completed

La CLI te proporcionará un STRIPE_WEBHOOK_SECRET temporal que debes usar en tu .env.local durante las pruebas.

Checklist antes de ir a producción

  1. Reemplaza las claves de test (sk_test_, pk_test_) por las de producción (sk_live_, pk_live_).
  2. Configura el webhook en el Dashboard de Stripe apuntando a tu URL de producción.
  3. Actualiza STRIPE_WEBHOOK_SECRET con el secreto del webhook de producción.
  4. Verifica que las páginas de éxito y error funcionan correctamente.
  5. Prueba el flujo completo con una tarjeta real (puedes hacer un reembolso después).

Buenas prácticas de seguridad

  • Nunca confíes solo en el cliente: Siempre verifica los pagos a través de webhooks en el servidor. La redirección a la página de éxito no garantiza que el pago se haya completado.
  • Valida la firma del webhook: Usa siempre stripe.webhooks.constructEvent() para verificar que los eventos realmente provienen de Stripe.
  • Protege tus claves: La clave secreta (sk_) nunca debe estar en el código del cliente ni en repositorios públicos. Usa variables de entorno.
  • Idempotencia: Los webhooks pueden reenviarse. Asegúrate de que tu lógica de negocio maneja duplicados correctamente (usa el event.id como clave de idempotencia).
  • HTTPS obligatorio: Stripe requiere HTTPS para los webhooks en producción. En Vercel, esto viene configurado por defecto.
  • Logs y monitorización: Registra todos los eventos de webhook para poder diagnosticar problemas. Stripe también ofrece un dashboard de eventos con reintentos automáticos.

Consejo final: Empieza siempre en modo test, valida todo el flujo de pago y webhooks antes de cambiar a producción. Stripe ofrece documentación excelente y un soporte técnico de calidad si necesitas ayuda con casos edge.

Compartir:

Artículos relacionados