Stripe Subscriptions

Recurring billing with Stripe subscriptions.

Dependencies#

npm install stripe @stripe/stripe-js

Environment 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_yearly

Database 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}