Stripe Subscriptions
Recurring billing with Stripe subscriptions.
Dependencies#
npm install stripe @stripe/stripe-jsEnvironment Variables#
1# .env.local
2STRIPE_SECRET_KEY=sk_test_...
3NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
4STRIPE_WEBHOOK_SECRET=whsec_...
5NEXT_PUBLIC_APP_URL=http://localhost:3000
6
7# Price IDs from Stripe Dashboard
8STRIPE_PRICE_FREE=price_free
9STRIPE_PRICE_PRO_MONTHLY=price_pro_monthly
10STRIPE_PRICE_PRO_YEARLY=price_pro_yearlyDatabase Schema#
1// prisma/schema.prisma
2model User {
3 id String @id @default(cuid())
4 clerkId String @unique
5 email String @unique
6 name String?
7
8 // Stripe
9 stripeCustomerId String? @unique
10 subscriptionId String?
11 subscriptionStatus String?
12 priceId String?
13 currentPeriodEnd DateTime?
14
15 createdAt DateTime @default(now())
16 updatedAt DateTime @updatedAt
17
18 @@index([stripeCustomerId])
19}Subscription Plans Config#
1// lib/plans.ts
2export const PLANS = {
3 free: {
4 name: 'Free',
5 description: 'For individuals getting started',
6 price: { monthly: 0, yearly: 0 },
7 priceId: { monthly: null, yearly: null },
8 features: [
9 '5 projects',
10 '1,000 API calls/month',
11 'Community support',
12 ],
13 limits: {
14 projects: 5,
15 apiCalls: 1000,
16 },
17 },
18 pro: {
19 name: 'Pro',
20 description: 'For professionals and small teams',
21 price: { monthly: 19, yearly: 190 },
22 priceId: {
23 monthly: process.env.STRIPE_PRICE_PRO_MONTHLY!,
24 yearly: process.env.STRIPE_PRICE_PRO_YEARLY!,
25 },
26 features: [
27 'Unlimited projects',
28 '100,000 API calls/month',
29 'Priority support',
30 'Advanced analytics',
31 ],
32 limits: {
33 projects: Infinity,
34 apiCalls: 100000,
35 },
36 },
37} as const;
38
39export type PlanName = keyof typeof PLANS;
40
41export function getPlanByPriceId(priceId: string): PlanName {
42 for (const [name, plan] of Object.entries(PLANS)) {
43 if (plan.priceId.monthly === priceId || plan.priceId.yearly === priceId) {
44 return name as PlanName;
45 }
46 }
47 return 'free';
48}Create Subscription#
1// app/api/subscriptions/route.ts
2import { auth } from '@clerk/nextjs/server';
3import { NextRequest, NextResponse } from 'next/server';
4import { stripe } from '@/lib/stripe';
5import { prisma } from '@/lib/prisma';
6
7export async function POST(request: NextRequest) {
8 const { userId } = await auth();
9 if (!userId) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12
13 const { priceId } = await request.json();
14
15 const user = await prisma.user.findUnique({
16 where: { clerkId: userId },
17 });
18
19 if (!user) {
20 return NextResponse.json({ error: 'User not found' }, { status: 404 });
21 }
22
23 // Get or create Stripe customer
24 let customerId = user.stripeCustomerId;
25
26 if (!customerId) {
27 const customer = await stripe.customers.create({
28 email: user.email,
29 metadata: { userId: user.id },
30 });
31 customerId = customer.id;
32
33 await prisma.user.update({
34 where: { id: user.id },
35 data: { stripeCustomerId: customerId },
36 });
37 }
38
39 // Create checkout session for subscription
40 const session = await stripe.checkout.sessions.create({
41 customer: customerId,
42 mode: 'subscription',
43 payment_method_types: ['card'],
44 line_items: [{ price: priceId, quantity: 1 }],
45 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
46 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
47 subscription_data: {
48 metadata: { userId: user.id },
49 },
50 });
51
52 return NextResponse.json({ url: session.url });
53}Manage Subscription (Customer Portal)#
1// app/api/subscriptions/portal/route.ts
2import { auth } from '@clerk/nextjs/server';
3import { NextResponse } from 'next/server';
4import { stripe } from '@/lib/stripe';
5import { prisma } from '@/lib/prisma';
6
7export async function POST() {
8 const { userId } = await auth();
9 if (!userId) {
10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11 }
12
13 const user = await prisma.user.findUnique({
14 where: { clerkId: userId },
15 select: { stripeCustomerId: true },
16 });
17
18 if (!user?.stripeCustomerId) {
19 return NextResponse.json(
20 { error: 'No billing account found' },
21 { status: 400 }
22 );
23 }
24
25 const session = await stripe.billingPortal.sessions.create({
26 customer: user.stripeCustomerId,
27 return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/settings`,
28 });
29
30 return NextResponse.json({ url: session.url });
31}Webhook Handler#
1// app/api/webhooks/stripe/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { stripe } from '@/lib/stripe';
4import { prisma } from '@/lib/prisma';
5import Stripe from 'stripe';
6
7export async function POST(request: NextRequest) {
8 const body = await request.text();
9 const signature = request.headers.get('stripe-signature')!;
10
11 let event: Stripe.Event;
12
13 try {
14 event = stripe.webhooks.constructEvent(
15 body,
16 signature,
17 process.env.STRIPE_WEBHOOK_SECRET!
18 );
19 } catch (err: any) {
20 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
21 }
22
23 switch (event.type) {
24 case 'checkout.session.completed': {
25 const session = event.data.object as Stripe.Checkout.Session;
26 if (session.mode === 'subscription') {
27 await handleSubscriptionCreated(session);
28 }
29 break;
30 }
31
32 case 'customer.subscription.updated': {
33 const subscription = event.data.object as Stripe.Subscription;
34 await handleSubscriptionUpdated(subscription);
35 break;
36 }
37
38 case 'customer.subscription.deleted': {
39 const subscription = event.data.object as Stripe.Subscription;
40 await handleSubscriptionDeleted(subscription);
41 break;
42 }
43
44 case 'invoice.payment_succeeded': {
45 const invoice = event.data.object as Stripe.Invoice;
46 await handlePaymentSucceeded(invoice);
47 break;
48 }
49
50 case 'invoice.payment_failed': {
51 const invoice = event.data.object as Stripe.Invoice;
52 await handlePaymentFailed(invoice);
53 break;
54 }
55 }
56
57 return NextResponse.json({ received: true });
58}
59
60async function handleSubscriptionCreated(session: Stripe.Checkout.Session) {
61 const subscription = await stripe.subscriptions.retrieve(
62 session.subscription as string
63 );
64
65 await prisma.user.update({
66 where: { stripeCustomerId: session.customer as string },
67 data: {
68 subscriptionId: subscription.id,
69 subscriptionStatus: subscription.status,
70 priceId: subscription.items.data[0].price.id,
71 currentPeriodEnd: new Date(subscription.current_period_end * 1000),
72 },
73 });
74}
75
76async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
77 await prisma.user.update({
78 where: { stripeCustomerId: subscription.customer as string },
79 data: {
80 subscriptionStatus: subscription.status,
81 priceId: subscription.items.data[0].price.id,
82 currentPeriodEnd: new Date(subscription.current_period_end * 1000),
83 },
84 });
85}
86
87async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
88 await prisma.user.update({
89 where: { stripeCustomerId: subscription.customer as string },
90 data: {
91 subscriptionId: null,
92 subscriptionStatus: 'canceled',
93 priceId: null,
94 currentPeriodEnd: null,
95 },
96 });
97}
98
99async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
100 // Record successful payment, send receipt, etc.
101 console.log('Payment succeeded for:', invoice.customer);
102}
103
104async function handlePaymentFailed(invoice: Stripe.Invoice) {
105 // Notify user of failed payment
106 console.log('Payment failed for:', invoice.customer);
107}Subscription Status Check#
1// lib/subscription.ts
2import { prisma } from '@/lib/prisma';
3import { PLANS, PlanName, getPlanByPriceId } from './plans';
4
5export async function getUserSubscription(userId: string) {
6 const user = await prisma.user.findUnique({
7 where: { clerkId: userId },
8 select: {
9 subscriptionId: true,
10 subscriptionStatus: true,
11 priceId: true,
12 currentPeriodEnd: true,
13 },
14 });
15
16 if (!user) return null;
17
18 const isActive =
19 user.subscriptionStatus === 'active' ||
20 user.subscriptionStatus === 'trialing';
21
22 const plan = user.priceId ? getPlanByPriceId(user.priceId) : 'free';
23
24 return {
25 plan,
26 isActive,
27 subscriptionId: user.subscriptionId,
28 currentPeriodEnd: user.currentPeriodEnd,
29 limits: PLANS[plan].limits,
30 };
31}
32
33export async function checkLimit(
34 userId: string,
35 type: 'projects' | 'apiCalls',
36 current: number
37): Promise<boolean> {
38 const subscription = await getUserSubscription(userId);
39 if (!subscription) return false;
40
41 const limit = subscription.limits[type];
42 return current < limit;
43}Pricing Page Component#
1// components/PricingTable.tsx
2'use client';
3
4import { useState } from 'react';
5import { Check } from 'lucide-react';
6import { PLANS } from '@/lib/plans';
7
8export function PricingTable({
9 currentPlan,
10}: {
11 currentPlan?: string;
12}) {
13 const [interval, setInterval] = useState<'monthly' | 'yearly'>('monthly');
14
15 const handleSubscribe = async (priceId: string) => {
16 const response = await fetch('/api/subscriptions', {
17 method: 'POST',
18 headers: { 'Content-Type': 'application/json' },
19 body: JSON.stringify({ priceId }),
20 });
21
22 const { url } = await response.json();
23 window.location.href = url;
24 };
25
26 return (
27 <div>
28 {/* Interval Toggle */}
29 <div className="flex justify-center mb-8">
30 <div className="bg-gray-100 dark:bg-gray-800 p-1 rounded-lg inline-flex">
31 <button
32 onClick={() => setInterval('monthly')}
33 className={`px-4 py-2 rounded-md ${
34 interval === 'monthly'
35 ? 'bg-white dark:bg-gray-700 shadow'
36 : ''
37 }`}
38 >
39 Monthly
40 </button>
41 <button
42 onClick={() => setInterval('yearly')}
43 className={`px-4 py-2 rounded-md ${
44 interval === 'yearly'
45 ? 'bg-white dark:bg-gray-700 shadow'
46 : ''
47 }`}
48 >
49 Yearly
50 <span className="ml-1 text-green-600 text-sm">Save 17%</span>
51 </button>
52 </div>
53 </div>
54
55 {/* Plans */}
56 <div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
57 {Object.entries(PLANS).map(([key, plan]) => (
58 <div
59 key={key}
60 className={`border rounded-2xl p-8 ${
61 key === 'pro'
62 ? 'border-brand-500 ring-2 ring-brand-500'
63 : 'border-gray-200 dark:border-gray-700'
64 }`}
65 >
66 <h3 className="text-2xl font-bold">{plan.name}</h3>
67 <p className="text-gray-600 dark:text-gray-400 mt-2">
68 {plan.description}
69 </p>
70
71 <div className="mt-6">
72 <span className="text-4xl font-bold">
73 ${plan.price[interval]}
74 </span>
75 {plan.price[interval] > 0 && (
76 <span className="text-gray-500">/{interval === 'monthly' ? 'mo' : 'yr'}</span>
77 )}
78 </div>
79
80 <ul className="mt-6 space-y-3">
81 {plan.features.map((feature) => (
82 <li key={feature} className="flex items-center gap-2">
83 <Check className="w-5 h-5 text-green-500" />
84 <span>{feature}</span>
85 </li>
86 ))}
87 </ul>
88
89 <button
90 onClick={() =>
91 plan.priceId[interval] &&
92 handleSubscribe(plan.priceId[interval]!)
93 }
94 disabled={currentPlan === key}
95 className={`w-full mt-8 py-3 rounded-lg font-medium ${
96 key === 'pro'
97 ? 'bg-brand-600 text-white hover:bg-brand-700'
98 : 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200'
99 } disabled:opacity-50`}
100 >
101 {currentPlan === key ? 'Current Plan' : 'Get Started'}
102 </button>
103 </div>
104 ))}
105 </div>
106 </div>
107 );
108}Billing Settings Component#
1// components/BillingSettings.tsx
2'use client';
3
4import { useState } from 'react';
5import { format } from 'date-fns';
6
7interface BillingSettingsProps {
8 subscription: {
9 plan: string;
10 isActive: boolean;
11 currentPeriodEnd: Date | null;
12 };
13}
14
15export function BillingSettings({ subscription }: BillingSettingsProps) {
16 const [loading, setLoading] = useState(false);
17
18 const openPortal = async () => {
19 setLoading(true);
20 const response = await fetch('/api/subscriptions/portal', {
21 method: 'POST',
22 });
23 const { url } = await response.json();
24 window.location.href = url;
25 };
26
27 return (
28 <div className="bg-white dark:bg-gray-900 rounded-xl border p-6">
29 <h2 className="text-xl font-semibold mb-4">Subscription</h2>
30
31 <div className="space-y-4">
32 <div className="flex justify-between">
33 <span className="text-gray-600 dark:text-gray-400">Plan</span>
34 <span className="font-medium capitalize">{subscription.plan}</span>
35 </div>
36
37 <div className="flex justify-between">
38 <span className="text-gray-600 dark:text-gray-400">Status</span>
39 <span
40 className={`font-medium ${
41 subscription.isActive ? 'text-green-600' : 'text-red-600'
42 }`}
43 >
44 {subscription.isActive ? 'Active' : 'Inactive'}
45 </span>
46 </div>
47
48 {subscription.currentPeriodEnd && (
49 <div className="flex justify-between">
50 <span className="text-gray-600 dark:text-gray-400">
51 Next billing date
52 </span>
53 <span className="font-medium">
54 {format(subscription.currentPeriodEnd, 'MMM d, yyyy')}
55 </span>
56 </div>
57 )}
58 </div>
59
60 <button
61 onClick={openPortal}
62 disabled={loading}
63 className="mt-6 w-full py-2 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800"
64 >
65 {loading ? 'Loading...' : 'Manage Subscription'}
66 </button>
67 </div>
68 );
69}