Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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 });
}// 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>
);
}stripe.billingPortal.sessions.create genera una URL de corta duración (válida durante unos minutos) que inicia sesión del cliente en el portal.return_url es adónde Stripe redirige al cliente después de que abandone el portal.customer.subscription.updated) que tu manejador de webhooks debe procesar.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,
},
},
});stripe.billingPortal.sessions.create devuelve Promise<Stripe.BillingPortal.Session>.url de la sesión del portal siempre es un string (nunca null), a diferencia de las Checkout Sessions.Stripe.BillingPortal.Configuration.import type Stripe from "stripe";
type PortalSession = Stripe.BillingPortal.Session;customer.subscription.updated y customer.subscription.deleted para mantener tu base de datos sincronizada.redirect() en una Server Action lanza internamente. No captures este error o la redirección no ocurrirá.| 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 |
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);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.
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á.
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "...",
flow_data: {
type: "subscription_cancel",
subscription_cancel: { subscription: subscriptionId },
},
});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.
customer.subscription.updated - cambios de plan, actualizaciones de métodos de pagocustomer.subscription.deleted - suscripción canceladaredirect() 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.
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 },
},
});Devuelve Promise<Stripe.BillingPortal.Session>. A diferencia de las Checkout Sessions, la propiedad url siempre es un string (nunca null).
redirect() directamenteRevisado por Chris St. John·Última actualización: 7 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥