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
-
stripenpm package installed - Auth.js v5 session (or any session that gives you
userId)
1. Install Stripe
npm install stripe
Set your env vars:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
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',
});
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>
);
}
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>
);
}
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 });
}
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>
);
}
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 });
}
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.txtfor AI agent compatibility
Or star the repo: github.com/huangyongshan46-a11y/launchkit-saas
Top comments (0)