Subscription Actions
Users need control over their subscriptions - updating payment methods, viewing invoices, cancelling, or changing plans. Stripe's Customer Portal handles all of this on a hosted page, so you don't need to build these features yourself. You just redirect users there.
Outcome
Add a "Manage Subscription" button that redirects users to the Stripe Customer Portal.
Fast Track
- Implement
createStripePortal()Server Action inutils/stripe/server.ts - Create
components/subscription-actions.tsxwith the button - Add the component to the subscription page
Hands-on Exercise 2.5
Add subscription management via Stripe Portal:
Requirements:
- Implement
createStripePortal()Server Action to create portal sessions - Create a client component with "Manage Subscription" button
- Redirect to Stripe Portal URL on click
- Show loading state during portal creation
- Add the component to the subscription page
Implementation hints:
- Use
stripe.billingPortal.sessions.create()to create a portal session - The portal needs the Stripe customer ID
- Use
router.push()to redirect to the portal URL - Configure your portal settings in Stripe dashboard
Try It
-
Configure portal in Stripe:
- Go to Stripe Dashboard → Settings → Customer Portal
- Enable the features you want (cancel, update payment, etc.)
- Save configuration
-
Start dev server:
pnpm dev -
Navigate to subscription:
- Sign in with an account that has a subscription
- Go to http://localhost:3000/protected/subscription
-
Click "Manage Subscription":
- You should be redirected to Stripe's Customer Portal
- The portal shows billing history, payment methods, and cancel option
-
Return to app:
- Use the portal's "Return to..." link
- You should land back on your subscription page
Commit
git add -A
git commit -m "feat(billing): add subscription portal access"Done-When
createStripePortal()Server Action creates portal sessions- "Manage Subscription" button appears for active subscriptions
- Clicking button redirects to Stripe Customer Portal
- Loading state shows while creating session
- Portal shows billing management options
- Return link brings user back to app
Solution
Step 1: Implement Portal Server Action
Add createStripePortal() to utils/stripe/server.ts:
"use server";
import { stripe } from "./config";
import { createSupabaseClient } from "@/utils/supabase/server";
import { createOrRetrieveCustomer } from "@/utils/supabase/admin";
function getURL(path: string = "") {
let url =
process.env.NEXT_PUBLIC_SITE_URL ??
process.env.VERCEL_URL ??
"http://localhost:3000";
url = url.startsWith("http") ? url : `https://${url}`;
url = url.endsWith("/") ? url.slice(0, -1) : url;
return path ? `${url}${path}` : url;
}
// ... checkoutWithStripe from lesson 2.3 ...
export async function createStripePortal(
currentPath: string = "/protected/subscription"
): Promise<string> {
try {
const supabase = await createSupabaseClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
throw new Error("Could not get user session.");
}
const customer = await createOrRetrieveCustomer({
uuid: user.id,
email: user.email || "",
});
const { url } = await stripe.billingPortal.sessions.create({
customer,
return_url: getURL(currentPath),
});
if (!url) {
throw new Error("Could not create billing portal");
}
return url;
} catch (error) {
console.error("Error creating portal:", error);
throw error;
}
}The portal session:
customer- The Stripe customer ID (required)return_url- Where users land after leaving the portal
Step 2: Create Subscription Actions Component
Create components/subscription-actions.tsx:
"use client";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { createStripePortal } from "@/utils/stripe/server";
import { SubscriptionWithPrice } from "@/utils/supabase/queries";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function SubscriptionActions({
subscription,
}: {
subscription: SubscriptionWithPrice;
}) {
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
async function handleManageSubscription() {
setIsLoading(true);
try {
const portalUrl = await createStripePortal();
router.push(portalUrl);
} catch (error) {
console.error("Error opening portal:", error);
setIsLoading(false);
}
}
return (
<div className="flex gap-2">
<Button
onClick={handleManageSubscription}
disabled={isLoading}
variant="outline"
className="flex-1"
>
<Spinner variant="primary" isLoading={isLoading} />
{isLoading ? "Loading..." : "Manage Subscription"}
</Button>
</div>
);
}Key patterns:
- Client component - Uses
useStateand event handlers - Server Action call -
createStripePortal()runs on the server - Redirect -
router.push()navigates to the portal URL - Loading state - Shows spinner while creating session
Step 3: Update Subscription Page
Update app/protected/subscription/page.tsx to include the actions:
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { createSupabaseClient } from "@/utils/supabase/server";
import { getSubscription } from "@/utils/supabase/queries";
import SubscriptionActions from "@/components/subscription-actions";
export default async function Page() {
const supabase = await createSupabaseClient();
const {
data: { user },
} = await supabase.auth.getUser();
const subscription = await getSubscription(supabase);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
};
const formatPrice = (amount: number | null, currency: string) => {
if (!amount) return "N/A";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency || "usd",
minimumFractionDigits: 0,
}).format(amount / 100);
};
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<h1 className="text-2xl font-medium">My Membership</h1>
<p className="text-muted-foreground mt-2">
Manage your guild membership
</p>
</div>
</div>
<div className="space-y-6">
{!subscription ? (
<Card className="p-6">
<h2 className="font-medium">No Active Membership</h2>
<p className="text-muted-foreground mt-2">
You haven't joined a membership tier yet. Visit the Membership
page to choose a tier and unlock guild benefits.
</p>
<Button className="mt-4" asChild>
<Link href="/protected/pricing">View Membership Tiers</Link>
</Button>
</Card>
) : (
<Card className="p-6 space-y-4">
{/* ... existing subscription details ... */}
<SubscriptionActions subscription={subscription} />
</Card>
)}
</div>
<p className="text-sm text-muted-foreground">
Signed in as: {user?.email}
</p>
</div>
);
}How the Portal Works
User clicks "Manage Subscription"
↓
handleManageSubscription()
↓
createStripePortal() Server Action
↓
createOrRetrieveCustomer() → Get Stripe customer ID
↓
stripe.billingPortal.sessions.create()
↓
Returns { url: "https://billing.stripe.com/..." }
↓
router.push(url)
↓
Stripe Customer Portal
↓
User manages billing
↓
Click "Return to..."
↓
Redirect to return_url (/protected/subscription)
Stripe Portal Features
Configure what users can do in Stripe Dashboard → Customer Portal:
| Feature | Description |
|---|---|
| Update payment method | Add/remove cards |
| View invoices | Download PDF invoices |
| Cancel subscription | Cancel at period end |
| Switch plans | Upgrade/downgrade |
| Update billing info | Change address/email |
Enable only the features you want users to access.
File Structure After This Lesson
utils/stripe/
├── config.ts ← Server Stripe client
├── client.ts ← Browser Stripe loader
└── server.ts ← Updated: + createStripePortal
components/
├── pricing-card.tsx ← Checkout flow
└── subscription-actions.tsx ← New: portal button
Section Complete
You've now built a complete Stripe integration:
- Lesson 2.1: Configured Stripe SDK for server and browser
- Lesson 2.2: Built pricing page with product tiers
- Lesson 2.3: Implemented Stripe Checkout flow
- Lesson 2.4: Created subscription management page
- Lesson 2.5: Added Customer Portal access
Next up in Section 3: You'll learn about entitlements - how to gate features based on subscription status.
Troubleshooting
"Could not get user session" error:
- User must be signed in before accessing the portal
- Verify the Supabase session is valid
- Check cookies are being sent with the request
Portal shows "No active subscriptions":
- Verify the user has a subscription in Stripe
- Check that the customer ID mapping is correct
- Ensure the subscription was created for this customer
"Return to" link goes to wrong URL:
- Check
return_urlincreateStripePortal() - Verify
NEXT_PUBLIC_SITE_URLis set correctly in production
Portal doesn't show expected options:
- Configure the portal in Stripe Dashboard
- Some features (like switching plans) require additional setup
- Test mode and live mode have separate configurations
Was this helpful?