Busque em todas as páginas da documentação
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Crie uma sessão de portal no servidor e redirecione o cliente para a página hospedada pelo Stripe para gerenciamento de faturamento, onde ele pode atualizar métodos de pagamento, alterar planos, cancelar assinaturas e visualizar o histórico de faturas.
Crie uma Server Action para o redirecionamento do 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("Não autenticado");
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
throw new Error("Nenhum cliente Stripe encontrado");
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);
}Ou use um 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: "Não autorizado" }, { status: 401 });
}
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ error: "Nenhuma conta de faturamento" },
{ 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">Configurações da Conta</h1>
<div className="border rounded-lg p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">Assinatura</h2>
<dl className="space-y-2">
<div className="flex justify-between">
<dt className="text-gray-600">Plano Atual</dt>
<dd className="font-medium capitalize">{user?.plan ?? "Gratuito"}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600">Status</dt>
<dd className="font-medium capitalize">
{user?.planStatus ?? "N/A"}
</dd>
</div>
{user?.currentPeriodEnd && (
<div className="flex justify-between">
<dt className="text-gray-600">Período Atual Termina em</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"
>
Gerenciar Assinatura
</button>
</form>
) : (
<a
href="/pricing"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700"
>
Ver Planos
</a>
)}
</div>
);
}Uma versão de componente cliente com estado de carregamento:
// 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 ? "Abrindo portal..." : "Gerenciar Assinatura"}
</button>
);
}stripe.billingPortal.sessions.create gera uma URL de curta duração (válida por alguns minutos) que faz login do cliente no portal.return_url é para onde o Stripe redireciona o cliente após ele sair do portal.customer.subscription.updated) que seu manipulador de webhook deve processar.Configurar o portal programaticamente:
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: "Gerencie sua assinatura",
},
});Link direto para uma seção específica do 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 retorna Promise<Stripe.BillingPortal.Session>.url na sessão do portal é sempre uma string (nunca nula), ao contrário das Sessões de Checkout.Stripe.BillingPortal.Configuration.import type Stripe from "stripe";
type PortalSession = Stripe.BillingPortal.Session;customer.subscription.updated e customer.subscription.deleted para manter seu banco de dados sincronizado.redirect() em uma Server Action lança um erro internamente. Não capture este erro ou o redirecionamento não ocorrerá.| Abordagem | Prós | Contras |
|---|---|---|
| Portal do Cliente Stripe | Zero código de UI, lida com todo o gerenciamento de faturamento | Marca limitada, sai do seu aplicativo |
| UI de faturamento personalizada com a API Stripe | Controle total sobre design e fluxo | Esforço de desenvolvimento significativo |
| Portal com flow_data | Link direto para ações específicas | Ainda hospedado pelo Stripe |
| Portal incorporado (beta) | Permanece no seu aplicativo | Disponibilidade limitada |
const portalSession = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/account`,
});
redirect(portalSession.url);A página do portal é renderizada, mas fica quase vazia. Ela só mostra conteúdo significativo para clientes que têm pelo menos uma assinatura ou método de pagamento registrado.
No Stripe Dashboard em Configurações > Portal do Cliente. Você controla quais recursos estão disponíveis (cancelamento, troca de plano, atualizações de método de pagamento, histórico de faturas). Sem configuração no Dashboard, a criação de uma sessão falhará.
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: "...",
flow_data: {
type: "subscription_cancel",
subscription_cancel: { subscription: subscriptionId },
},
});As URLs de sessão do portal expiram em poucos minutos. Sempre gere uma URL nova quando o usuário clicar em "Gerenciar Assinatura" -- nunca armazene ou armazene em cache URLs do portal.
customer.subscription.updated -- alterações de plano, atualizações de método de pagamentocustomer.subscription.deleted -- assinatura canceladaredirect() lança um erro NEXT_REDIRECT internamente para acionar o redirecionamento. Se seu bloco try/catch capturar e engolir esse erro, o redirecionamento falhará silenciosamente e o usuário permanecerá na página atual.
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 },
},
});Ele retorna Promise<Stripe.BillingPortal.Session>. Ao contrário das Sessões de Checkout, a propriedade url é sempre uma string (nunca nula).
redirect() diretamenteRevisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥