Subscription billing is at the core of SaaS. Here is how to implement it with Stripe in Next.js.
Step 1: Setup
pnpm add stripe @stripe/stripe-js
// lib/stripe.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
typescript: true,
});
Step 2: Plan Configuration
// lib/plans.ts
export interface Plan {
id: string;
name: string;
description: string;
priceMonthly: number;
priceYearly: number;
stripePriceIdMonthly: string;
stripePriceIdYearly: string;
features: string[];
limits: {
projects: number;
members: number;
storage: number; // GB
};
popular?: boolean;
}
export const PLANS: Plan[] = [
{
id: "starter",
name: "Starter",
description: "For individuals and small projects",
priceMonthly: 9,
priceYearly: 90,
stripePriceIdMonthly: "price_starter_monthly",
stripePriceIdYearly: "price_starter_yearly",
features: ["5 projects", "1 team member", "5 GB storage", "Email support"],
limits: { projects: 5, members: 1, storage: 5 },
},
{
id: "pro",
name: "Pro",
description: "For growing teams",
priceMonthly: 29,
priceYearly: 290,
stripePriceIdMonthly: "price_pro_monthly",
stripePriceIdYearly: "price_pro_yearly",
features: [
"Unlimited projects",
"10 team members",
"50 GB storage",
"Priority support",
"Advanced analytics",
],
limits: { projects: -1, members: 10, storage: 50 },
popular: true,
},
{
id: "enterprise",
name: "Enterprise",
description: "For large organizations",
priceMonthly: 99,
priceYearly: 990,
stripePriceIdMonthly: "price_enterprise_monthly",
stripePriceIdYearly: "price_enterprise_yearly",
features: [
"Unlimited everything",
"Unlimited team members",
"500 GB storage",
"24/7 support",
"Custom integrations",
"SLA guarantee",
],
limits: { projects: -1, members: -1, storage: 500 },
},
];
export function getPlan(planId: string) {
return PLANS.find((p) => p.id === planId);
}
Step 3: Create Checkout Session
// app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { priceId, planId } = await request.json();
const checkoutSession = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
customer_email: session.user.email!,
metadata: {
userId: session.user.id,
planId,
},
subscription_data: {
metadata: {
userId: session.user.id,
planId,
},
trial_period_days: 14,
},
allow_promotion_codes: true,
});
return NextResponse.json({ url: checkoutSession.url });
}
Step 4: Customer Portal
// app/api/stripe/portal/route.ts
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { auth } from "@/lib/auth";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
export async function POST() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = await db
.select({ stripeCustomerId: users.stripeCustomerId })
.from(users)
.where(eq(users.id, session.user.id))
.limit(1);
if (!user[0]?.stripeCustomerId) {
return NextResponse.json({ error: "No subscription" }, { status: 400 });
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: user[0].stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return NextResponse.json({ url: portalSession.url });
}
Step 5: Webhook Handler
// app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import { stripe } from "@/lib/stripe";
import { db } from "@/db";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";
import type Stripe from "stripe";
export async function POST(request: NextRequest) {
const body = await request.text();
const signature = request.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const userId = session.metadata?.userId;
if (!userId) break;
await db
.update(users)
.set({
stripeCustomerId: session.customer as string,
stripeSubscriptionId: session.subscription as string,
plan: session.metadata?.planId ?? "starter",
subscriptionStatus: "active",
})
.where(eq(users.id, userId));
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata?.userId;
if (!userId) break;
await db
.update(users)
.set({
subscriptionStatus: subscription.status,
currentPeriodEnd: new Date(subscription.current_period_end * 1000).toISOString(),
})
.where(eq(users.id, userId));
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const userId = subscription.metadata?.userId;
if (!userId) break;
await db
.update(users)
.set({
plan: "free",
subscriptionStatus: "canceled",
stripeSubscriptionId: null,
})
.where(eq(users.id, userId));
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
// Notify user about failed payment
console.log(`Payment failed for customer: ${customerId}`);
break;
}
}
return NextResponse.json({ received: true });
}
Step 6: Billing Dashboard Component
"use client";
import { useState } from "react";
import { PLANS, type Plan } from "@/lib/plans";
interface BillingDashboardProps {
currentPlan: string;
subscriptionStatus: string;
currentPeriodEnd: string | null;
}
export function BillingDashboard({
currentPlan,
subscriptionStatus,
currentPeriodEnd,
}: BillingDashboardProps) {
const [isLoading, setIsLoading] = useState(false);
async function openPortal() {
setIsLoading(true);
const res = await fetch("/api/stripe/portal", { method: "POST" });
const { url } = await res.json();
window.location.href = url;
}
const plan = PLANS.find((p) => p.id === currentPlan);
return (
<div className="space-y-6">
<div className="rounded-xl border p-6 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">{plan?.name ?? "Free"} Plan</h3>
<p className="text-sm text-gray-500">{plan?.description}</p>
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
subscriptionStatus === "active"
? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: subscriptionStatus === "trialing"
? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
}`}
>
{subscriptionStatus}
</span>
</div>
{currentPeriodEnd && (
<p className="mt-2 text-sm text-gray-400">
{subscriptionStatus === "active" ? "Renews" : "Expires"}{" "}
{new Date(currentPeriodEnd).toLocaleDateString()}
</p>
)}
<button
onClick={openPortal}
disabled={isLoading}
className="mt-4 rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium hover:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700"
>
{isLoading ? "Loading..." : "Manage Subscription"}
</button>
</div>
{/* Usage */}
{plan && (
<div className="rounded-xl border p-6 dark:border-gray-700">
<h3 className="mb-4 text-sm font-semibold">Usage</h3>
<div className="space-y-3">
<UsageBar label="Projects" used={3} limit={plan.limits.projects} />
<UsageBar label="Team Members" used={1} limit={plan.limits.members} />
<UsageBar label="Storage" used={1.2} limit={plan.limits.storage} unit="GB" />
</div>
</div>
)}
</div>
);
}
function UsageBar({
label,
used,
limit,
unit = "",
}: {
label: string;
used: number;
limit: number;
unit?: string;
}) {
const isUnlimited = limit === -1;
const percentage = isUnlimited ? 0 : (used / limit) * 100;
const isNearLimit = percentage > 80;
return (
<div>
<div className="mb-1 flex items-center justify-between text-sm">
<span>{label}</span>
<span className="text-gray-500">
{used}
{unit} / {isUnlimited ? "Unlimited" : `${limit}${unit}`}
</span>
</div>
{!isUnlimited && (
<div className="h-2 rounded-full bg-gray-100 dark:bg-gray-800">
<div
className={`h-2 rounded-full transition-all ${
isNearLimit ? "bg-orange-500" : "bg-blue-500"
}`}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
)}
</div>
);
}
Summary
- Checkout sessions with trial periods and promo codes
- Stripe Customer Portal for self-service management
- Webhook handler for all subscription lifecycle events
- Billing dashboard with plan info and usage tracking
Need Subscription Billing?
We build complete billing systems with Stripe for SaaS applications. Contact us to get started.