Forem

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

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

Every SaaS app needs a billing page. It's where users manage their subscription, upgrade their plan, or cancel. Get it wrong and you lose trust. Get it right and it quietly handles customer success at scale.

In this guide, I'll show you how to build a production-ready billing page using Next.js 16 React Server Components and Stripe. We'll cover:

  • Fetching and displaying active subscription data
  • Embedding the Stripe Customer Portal
  • Handling plan upgrade redirects
  • Protecting the route by subscription status

This is the exact pattern used in LaunchKit — the Next.js 16 SaaS starter kit I built and sell.


Prerequisites

  • Next.js 16 app with App Router
  • Stripe account + secret key
  • stripe npm package installed
  • Auth.js v5 session (or any session that gives you userId)

1. Install Stripe

npm install stripe
Enter fullscreen mode Exit fullscreen mode

Set your env vars:

STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
Enter fullscreen mode Exit fullscreen mode

2. Create a Stripe Utility

// lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});
Enter fullscreen mode Exit fullscreen mode

3. The Billing Page (React Server Component)

// app/(dashboard)/billing/page.tsx
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
import { redirect } from 'next/navigation';
import { BillingCard } from '@/components/billing/billing-card';
import { PlanCard } from '@/components/billing/plan-card';

export default async function BillingPage() {
  const session = await auth();
  if (!session?.user?.id) redirect('/login');

  // Fetch user + subscription from your DB
  const user = await db.user.findUnique({
    where: { id: session.user.id },
    include: {
      subscription: true,
    },
  });

  if (!user) redirect('/login');

  // Fetch live subscription data from Stripe
  let stripeSubscription: Stripe.Subscription | null = null;

  if (user.subscription?.stripeSubscriptionId) {
    stripeSubscription = await stripe.subscriptions.retrieve(
      user.subscription.stripeSubscriptionId
    );
  }

  const isActive =
    stripeSubscription?.status === 'active' ||
    stripeSubscription?.status === 'trialing';

  return (
    <div className="max-w-2xl mx-auto py-10 px-4 space-y-6">
      <h1 className="text-2xl font-bold">Billing</h1>

      <BillingCard
        subscription={stripeSubscription}
        isActive={isActive}
        currentPeriodEnd={
          stripeSubscription?.current_period_end
            ? new Date(stripeSubscription.current_period_end * 1000)
            : null
        }
      />

      {!isActive && <PlanCard />}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

4. Billing Card Component

// components/billing/billing-card.tsx
'use client';

import { useState } from 'react';
import Stripe from 'stripe';

interface BillingCardProps {
  subscription: Stripe.Subscription | null;
  isActive: boolean;
  currentPeriodEnd: Date | null;
}

export function BillingCard({
  subscription,
  isActive,
  currentPeriodEnd,
}: BillingCardProps) {
  const [loading, setLoading] = useState(false);

  const openPortal = async () => {
    setLoading(true);
    try {
      const res = await fetch('/api/billing/portal', { method: 'POST' });
      const data = await res.json();
      if (data.url) window.location.href = data.url;
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="border border-border rounded-xl p-6 space-y-4">
      <div className="flex items-center justify-between">
        <h2 className="text-lg font-semibold">Current Plan</h2>
        <span
          className={`text-xs font-medium px-2 py-1 rounded-full ${
            isActive
              ? 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300'
              : 'bg-muted text-muted-foreground'
          }`}
        >
          {isActive ? 'Active' : 'Free'}
        </span>
      </div>

      {isActive && subscription && (
        <div className="space-y-1 text-sm text-muted-foreground">
          <p>
            Plan:{' '}
            <span className="text-foreground font-medium">
              {subscription.items.data[0]?.price.nickname ?? 'Pro'}
            </span>
          </p>
          {currentPeriodEnd && (
            <p>
              Renews:{' '}
              <span className="text-foreground">
                {currentPeriodEnd.toLocaleDateString()}
              </span>
            </p>
          )}
        </div>
      )}

      {isActive && (
        <button
          onClick={openPortal}
          disabled={loading}
          className="mt-2 w-full rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-muted transition-colors disabled:opacity-50"
        >
          {loading ? 'Opening portal...' : 'Manage Billing'}
        </button>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

5. Stripe Customer Portal API Route

// app/api/billing/portal/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: session.user.id },
  });

  if (!user?.stripeCustomerId) {
    return NextResponse.json({ error: 'No billing account found' }, { status: 400 });
  }

  const portalSession = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
  });

  return NextResponse.json({ url: portalSession.url });
}
Enter fullscreen mode Exit fullscreen mode

6. Plan Upgrade Card (for Free Users)

// components/billing/plan-card.tsx
'use client';

import { useState } from 'react';

export function PlanCard() {
  const [loading, setLoading] = useState(false);

  const startCheckout = async () => {
    setLoading(true);
    try {
      const res = await fetch('/api/billing/checkout', { method: 'POST' });
      const data = await res.json();
      if (data.url) window.location.href = data.url;
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="border border-border rounded-xl p-6 space-y-4 bg-muted/30">
      <h2 className="text-lg font-semibold">Upgrade to Pro</h2>
      <ul className="space-y-2 text-sm text-muted-foreground">
        <li> Unlimited AI usage</li>
        <li> Team collaboration</li>
        <li> Priority support</li>
        <li> All future updates</li>
      </ul>
      <button
        onClick={startCheckout}
        disabled={loading}
        className="w-full rounded-lg bg-primary text-primary-foreground px-4 py-2 text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50"
      >
        {loading ? 'Loading...' : 'Upgrade — $49/mo'}
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

7. Stripe Checkout Session Route

// app/api/billing/checkout/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { stripe } from '@/lib/stripe';
import { NextResponse } from 'next/server';

const PRICE_ID = process.env.STRIPE_PRO_PRICE_ID!;

export async function POST() {
  const session = await auth();
  if (!session?.user?.id) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: session.user.id },
  });

  if (!user) {
    return NextResponse.json({ error: 'User not found' }, { status: 404 });
  }

  // Create Stripe customer if they don't have one yet
  let customerId = user.stripeCustomerId;

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: user.email!,
      metadata: { userId: user.id },
    });
    customerId = customer.id;

    await db.user.update({
      where: { id: user.id },
      data: { stripeCustomerId: customerId },
    });
  }

  const checkoutSession = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    payment_method_types: ['card'],
    line_items: [{ price: PRICE_ID, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing`,
    subscription_data: {
      metadata: { userId: user.id },
    },
  });

  return NextResponse.json({ url: checkoutSession.url });
}
Enter fullscreen mode Exit fullscreen mode

What You Get

With this setup, your billing page:

  • Shows the user's active plan and renewal date
  • Opens the Stripe Customer Portal for cancellations, payment updates, and invoice history
  • Prompts free users to upgrade with a clean CTA
  • Handles checkout and redirects cleanly

No third-party billing UI libraries. Just Stripe + Next.js, clean and composable.


Skip the Wiring — Use LaunchKit

If you want this already built and wired up, I've packaged the entire billing system — including Stripe webhooks, subscription sync, and this UI — into LaunchKit.

It's a Next.js 16 SaaS starter kit with:

  • ✅ Auth.js v5 (Google, GitHub, email/password)
  • ✅ Stripe subscriptions + Customer Portal
  • ✅ Prisma schema for users, subscriptions, AI chat
  • ✅ OpenAI streaming chat
  • ✅ Dark mode UI
  • AGENTS.md + llms.txt for AI agent compatibility

👉 Get LaunchKit — $49

Or star the repo: github.com/huangyongshan46-a11y/launchkit-saas

Top comments (0)