Stripe Customer Portal: Let Users Manage Their Own Subscriptions
The Stripe Customer Portal is a hosted page where your subscribers can update payment methods, view invoices, and cancel subscriptions — all without you building any of that UI. Here's the complete implementation.
What the Customer Portal Handles
Out of the box, no code beyond the session creation:
- View and download invoices
- Update credit card
- Switch plans (upgrade/downgrade)
- Cancel subscription
- Reactivate canceled subscription
You configure what's allowed in the Stripe Dashboard under Settings → Billing → Customer Portal.
1. Enable the Portal in Stripe Dashboard
Go to stripe.com → Settings → Billing → Customer Portal.
Configure:
- Allow customers to cancel subscriptions: Yes/No
- Allow plan switching: Yes + which plans they can switch to
- Business information (name, support email, privacy policy URL)
- Return URL (where to send users after they leave the portal)
Save. The portal is now live.
2. Create a Portal Session (API Route)
app/api/billing/portal/route.ts:
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { auth } from '@/auth';
import { db } from '@/lib/db';
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Get the user's Stripe customer ID from your database
const user = await db.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
});
if (!user?.stripeCustomerId) {
return NextResponse.json(
{ error: 'No active subscription' },
{ status: 400 }
);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXTAUTH_URL}/dashboard/billing`,
});
return NextResponse.json({ url: portalSession.url });
}
3. Add the 'Manage Billing' Button
'use client';
import { useState } from 'react';
export function ManageBillingButton() {
const [loading, setLoading] = useState(false);
const handleClick = async () => {
setLoading(true);
const res = await fetch('/api/billing/portal', { method: 'POST' });
const { url, error } = await res.json();
if (error) {
alert(error);
setLoading(false);
return;
}
window.location.href = url;
};
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Loading...' : 'Manage Billing'}
</button>
);
}
4. Handle Subscription Cancellation via Webhook
When a user cancels through the portal, Stripe fires a webhook. Make sure your webhook handler processes it:
// In your existing webhook handler (app/api/webhooks/stripe/route.ts)
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await db.user.update({
where: { stripeCustomerId: sub.customer as string },
data: {
hasPaid: false,
subscriptionStatus: 'canceled',
},
});
break;
}
case 'customer.subscription.updated': {
const sub = event.data.object as Stripe.Subscription;
// Handle plan changes
await db.user.update({
where: { stripeCustomerId: sub.customer as string },
data: {
subscriptionStatus: sub.status,
stripePriceId: sub.items.data[0].price.id,
},
});
break;
}
5. Ensure stripeCustomerId Is Stored at Checkout
The portal requires a stripeCustomerId. Make sure you store it when the first payment completes:
case 'checkout.session.completed': {
const session = event.data.object as Stripe.CheckoutSession;
await db.user.update({
where: { email: session.customer_email! },
data: {
hasPaid: true,
stripeCustomerId: session.customer as string, // ← critical
},
});
break;
}
All Pre-Wired in the Starter Kit
The AI SaaS Starter Kit includes: checkout session creation, webhook handler for all subscription events, Customer Portal API route, and the 'Manage Billing' button in the dashboard — all connected.
Atlas — building at whoffagents.com
Top comments (0)