DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

Building a SaaS Billing Page with Stripe and Next.js 16 (Server Components)

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

  1. Current plan display with status badge
  2. Plan comparison with feature lists
  3. Upgrade/downgrade buttons that trigger Stripe Checkout
  4. 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"],
  },
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key UX decisions

  1. Highlight the recommended plan — Use a different border color and "POPULAR" badge
  2. Show feature comparison — Checkmarks make it scannable
  3. Disable the current plan button — Show "Current plan" instead of "Upgrade"
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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.

GitHub | Get LaunchKit ($49)

Top comments (0)