Your billing page is where free users become paying customers. Here's how to build one that actually converts — using React Server Components and Stripe.
What the billing page needs
- Current plan display with status badge
- Plan comparison with feature lists
- Upgrade/downgrade buttons that trigger Stripe Checkout
- Payment history
The plan config
Keep plan data centralized:
// src/lib/stripe.ts
export const PLANS = {
free: {
name: "Free",
price: { monthly: 0 },
features: ["Up to 3 projects", "Basic analytics", "Community support"],
},
pro: {
name: "Pro",
price: { monthly: 29 },
stripePriceId: process.env.STRIPE_PRO_PRICE_ID,
features: ["Unlimited projects", "Advanced analytics", "Priority support", "AI assistant (1000 msgs/mo)"],
},
enterprise: {
name: "Enterprise",
price: { monthly: 99 },
stripePriceId: process.env.STRIPE_ENTERPRISE_PRICE_ID,
features: ["Everything in Pro", "Unlimited team members", "SSO/SAML", "Unlimited AI", "SLA guarantee"],
},
};
The billing page (Server Component)
// src/app/(app)/billing/page.tsx
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { PLANS } from "@/lib/stripe";
import { redirect } from "next/navigation";
export default async function BillingPage() {
const session = await auth();
if (!session?.user) redirect("/login");
const subscription = await db.subscription.findUnique({
where: { userId: session.user.id },
});
const currentPlan = subscription?.plan?.toLowerCase() || "free";
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold">Billing</h1>
<p className="text-zinc-400 text-sm">Manage your subscription</p>
</div>
{/* Current plan */}
<div className="border border-zinc-800 rounded-xl p-6">
<h2 className="font-semibold mb-2">Current Plan</h2>
<div className="flex items-center gap-2">
<span className="bg-blue-500/20 text-blue-400 text-xs px-2 py-0.5 rounded-full">
{PLANS[currentPlan]?.name}
</span>
{subscription?.status === "ACTIVE" && (
<span className="bg-green-500/20 text-green-400 text-xs px-2 py-0.5 rounded-full">
Active
</span>
)}
</div>
</div>
{/* Plan cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{Object.entries(PLANS).map(([key, plan]) => (
<PlanCard
key={key}
planKey={key}
plan={plan}
isCurrent={key === currentPlan}
/>
))}
</div>
</div>
);
}
The upgrade button (Client Component)
"use client";
export function UpgradeButton({ priceId }: { priceId: string }) {
const [loading, setLoading] = useState(false);
async function handleUpgrade() {
setLoading(true);
const res = await fetch("/api/stripe/checkout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ priceId }),
});
const { url } = await res.json();
window.location.href = url; // Redirect to Stripe Checkout
}
return (
<button
onClick={handleUpgrade}
disabled={loading}
className="w-full bg-white text-zinc-900 py-2.5 rounded-lg font-medium hover:bg-zinc-100"
>
{loading ? "Redirecting..." : "Upgrade"}
</button>
);
}
Key UX decisions
- Highlight the recommended plan — Use a different border color and "POPULAR" badge
- Show feature comparison — Checkmarks make it scannable
- Disable the current plan button — Show "Current plan" instead of "Upgrade"
- Success/cancel handling — Check URL params after Stripe redirect
// Handle return from Stripe
const searchParams = useSearchParams();
if (searchParams.get("success")) {
// Show success toast
}
if (searchParams.get("canceled")) {
// Show "no changes made" message
}
Want the full implementation?
All of this (billing page + checkout API + webhook handler + plan gating) comes pre-wired in LaunchKit — a production-ready Next.js SaaS starter kit with auth, AI chat, email, and a beautiful dark UI.
Top comments (0)