Portal del cliente
Receta
Crea una sesión del portal en el servidor y redirige al cliente a la página alojada de gestión de facturación de Stripe donde puede actualizar métodos de pago, cambiar planes, cancelar suscripciones y ver el historial de facturas.
Crea una Server Action para la redirección al portal:
// app/actions/portal.ts
"use server";
import { stripe } from "@/lib/stripe";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
export async function createPortalSession() {
const session = await auth();
if (!session?.user?.id) throw new Error("Not authenticated");
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
throw new Error("No Stripe customer found");
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);
}O usa un Route Handler:
// app/api/portal/route.ts
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function POST() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ error: "No billing account" },
{ status: 404 }
);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
return NextResponse.json({ url: portalSession.url });
}Ejemplo funcional
// app/account/page.tsx
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { createPortalSession } from "@/app/actions/portal";
import { redirect } from "next/navigation";
export default async function AccountPage() {
const session = await auth();
if (!session?.user?.id) redirect("/login");
const user = await db.user.findUnique({
where: { id: session.user.id },
select: {
plan: true,
planStatus: true,
stripeCustomerId: true,
currentPeriodEnd: true,
},
});
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Configuración de la cuenta</h1>
<div className="border rounded-lg p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Suscripción</h2>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-gray-600">Plan actual</dt>
<dd className="font-medium capitalize">{user?.plan ?? "Gratis"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600">Estado</dt>
<dd className="font-medium capitalize">
{user?.planStatus ?? "N/A"}
</dd>
</div>
{user?.currentPeriodEnd && (
<div className="flex justify-between">
<dt className="text-gray-600">El período actual termina</dt>
<dd className="font-medium">
{user.currentPeriodEnd.toLocaleDateString()}
</dd>
</div>
)}
</dl>
</div>
{user?.stripeCustomerId ? (
<form action={createPortalSession}>
<button
type="submit"
className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-800"
>
Gestionar suscripción
</button>
</form>
) : (
<a
href="/pricing"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
>
Ver planes
</a>
)}
</div>
);
}Una versión con componente cliente y estado de carga:
// components/manage-billing-button.tsx
"use client";
import { useState } from "react";
import { createPortalSession } from "@/app/actions/portal";
export function ManageBillingButton() {
const [loading, setLoading] = useState(false);
async function handleClick() {
setLoading(true);
try {
await createPortalSession();
} catch {
setLoading(false);
}
}
return (
<button
onClick={handleClick}
disabled={loading}
className="bg-gray-900 text-white px-6 py-3 rounded-lg hover:bg-gray-800 disabled:opacity-50"
>
{loading ? "Abriendo portal..." : "Gestionar suscripción"}
</button>
);
}Profundización
Cómo funciona
- El Customer Portal es una página alojada por Stripe donde los clientes pueden autogestionar su facturación. Permite actualizar métodos de pago, cambiar de plan, cancelar suscripciones y ver facturas.
stripe.billingPortal.sessions.creategenera una URL de corta duración (válida durante unos minutos) que inicia sesión del cliente en el portal.- El
return_urles adónde Stripe redirige al cliente después de que abandone el portal. - El comportamiento del portal se configura en el Stripe Dashboard en Settings y Customer Portal. Tú controlas qué funciones están disponibles (cancelación, cambio de plan, etc.).
- Cuando un cliente hace cambios en el portal (p. ej., cancela), Stripe dispara eventos de webhook (p. ej.,
customer.subscription.updated) que tu manejador de webhooks debe procesar.
Variaciones
Configurar el portal de forma programática:
await stripe.billingPortal.configurations.create({
features: {
subscription_cancel: {
enabled: true,
mode: "at_period_end",
proration_behavior: "none",
},
subscription_update: {
enabled: true,
default_allowed_updates: ["price"],
proration_behavior: "create_prorations",
products: [
{
product: "prod_xxx",
prices: ["price_monthly", "price_annual"],
},
],
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
business_profile: {
headline: "Gestiona tu suscripción",
},
});Enlace en profundidad a una sección específica del portal:
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
flow_data: {
type: "subscription_cancel",
subscription_cancel: {
subscription: subscriptionId,
},
},
});Notas de TypeScript
stripe.billingPortal.sessions.createdevuelvePromise<Stripe.BillingPortal.Session>.- La propiedad
urlde la sesión del portal siempre es un string (nunca null), a diferencia de las Checkout Sessions. - Las configuraciones del portal están tipificadas como
Stripe.BillingPortal.Configuration.
import type Stripe from "stripe";
type PortalSession = Stripe.BillingPortal.Session;Errores comunes
- El Customer Portal debe configurarse en el Stripe Dashboard antes de usarlo. Sin configuración, crear una sesión fallará.
- Las sesiones del portal caducan rápidamente. Genera siempre una URL nueva cuando el cliente haga clic en "Gestionar suscripción" — nunca guardes ni almacenes en caché las URLs del portal.
- El portal solo funciona para clientes que tienen al menos una suscripción o un método de pago registrado. Crear una sesión del portal para un cliente sin historial de facturación mostrará una página casi vacía.
- Los cambios hechos en el portal disparan eventos de webhook. Asegúrate de que tu manejador de webhooks procese
customer.subscription.updatedycustomer.subscription.deletedpara mantener tu base de datos sincronizada. - La llamada a
redirect()en una Server Action lanza internamente. No captures este error o la redirección no ocurrirá. - Si permites el cambio de plan en el portal, asegúrate de que los IDs de producto y precio en la configuración del portal coincidan con tus productos reales de Stripe.
Alternativas
| Enfoque | Ventajas | Desventajas |
|---|---|---|
| Stripe Customer Portal | Cero código de UI, gestiona toda la facturación | Branding limitado, saca al usuario de tu app |
| UI de facturación personalizada con la API de Stripe | Control total sobre diseño y flujo | Esfuerzo de desarrollo significativo |
| Portal con flow_data | Enlace en profundidad a acciones específicas | Sigue alojado en Stripe |
| Portal integrado (beta) | Permanece en tu app | Disponibilidad limitada |
FAQs
¿Qué pueden hacer los clientes en el Stripe Customer Portal?
- Actualizar métodos de pago
- Cambiar entre planes de suscripción
- Cancelar suscripciones
- Ver y descargar el historial de facturas
¿Cómo creas una sesión del portal y rediriges al usuario?
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);Trampa común: ¿Qué ocurre si creas una sesión del portal para un cliente sin historial de facturación?
La página del portal se renderiza pero está casi vacía. Solo muestra contenido significativo para clientes que tienen al menos una suscripción o un método de pago registrado.
¿Dónde se configura el Customer Portal?
En el Stripe Dashboard en Settings > Customer Portal. Tú controlas qué funciones están disponibles (cancelación, cambio de plan, actualización de métodos de pago, historial de facturas). Sin configuración en el Dashboard, crear una sesión fallará.
¿Puedes enlazar en profundidad a una acción específica del portal como la cancelación?
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "...",
flow_data: {
type: "subscription_cancel",
subscription_cancel: { subscription: subscriptionId },
},
});¿Cuánto tiempo son válidas las URLs de sesión del portal?
Las URLs de sesión del portal caducan en unos minutos. Genera siempre una URL nueva cuando el usuario haga clic en "Gestionar suscripción" — nunca guardes ni almacenes en caché las URLs del portal.
¿Qué eventos de webhook debes gestionar cuando los clientes hacen cambios en el portal?
customer.subscription.updated— cambios de plan, actualizaciones de métodos de pagocustomer.subscription.deleted— suscripción cancelada- Mantén siempre tu base de datos sincronizada procesando estos eventos en tu manejador de webhooks
Trampa común: ¿Por qué no debes capturar el error que lanza redirect() en una Server Action?
redirect() lanza internamente un error NEXT_REDIRECT para provocar la redirección. Si tu bloque try/catch captura y suprime este error, la redirección falla en silencio y el usuario permanece en la página actual.
¿Cómo configuras el portal de forma programática en lugar de a través del Dashboard?
await stripe.billingPortal.configurations.create({
features: {
subscription_cancel: { enabled: true, mode: "at_period_end" },
subscription_update: {
enabled: true,
default_allowed_updates: ["price"],
products: [{ product: "prod_xxx", prices: ["price_a", "price_b"] }],
},
payment_method_update: { enabled: true },
invoice_history: { enabled: true },
},
});TypeScript: ¿Qué tipo devuelve stripe.billingPortal.sessions.create?
Devuelve Promise<Stripe.BillingPortal.Session>. A diferencia de las Checkout Sessions, la propiedad url siempre es un string (nunca null).
¿Cuál es la diferencia entre usar una Server Action y un Route Handler para la redirección al portal?
- Server Action: más simple, llámala desde una acción de formulario, usa
redirect()directamente - Route Handler: devuelve JSON con la URL, el cliente gestiona la redirección, útil para flujos que no son formularios
- Ambos requieren comprobaciones de autenticación antes de crear la sesión del portal