Subscription Patterns

Manage recurring billing with Stripe Subscriptions.

Overview#

Subscriptions power SaaS businesses. This pattern covers:

  • Subscription plan configuration
  • Creating subscriptions
  • Plan changes and upgrades
  • Cancellation and reactivation
  • Usage limits based on plan

Prerequisites#

npm install stripe

Define Subscription Plans#

Configure your pricing tiers.

1// lib/plans.ts 2export const PLANS = { 3 free: { 4 name: 'Free', 5 priceId: null, 6 limits: { 7 projects: 1, 8 storage: 100, // MB 9 teamMembers: 1, 10 apiCalls: 1000 11 } 12 }, 13 pro: { 14 name: 'Pro', 15 priceId: process.env.STRIPE_PRO_PRICE_ID!, 16 limits: { 17 projects: 10, 18 storage: 5000, 19 teamMembers: 5, 20 apiCalls: 50000 21 } 22 }, 23 enterprise: { 24 name: 'Enterprise', 25 priceId: process.env.STRIPE_ENTERPRISE_PRICE_ID!, 26 limits: { 27 projects: -1, // unlimited 28 storage: -1, 29 teamMembers: -1, 30 apiCalls: -1 31 } 32 } 33} as const 34 35export type PlanId = keyof typeof PLANS 36export type Plan = typeof PLANS[PlanId]

Create Subscription#

Subscribe a user to a plan.

1// lib/billing.ts 2import { stripe } from '@/lib/stripe' 3import { prisma } from '@/lib/db' 4import { PLANS, PlanId } from './plans' 5 6export async function createSubscription(userId: string, planId: PlanId) { 7 const user = await prisma.user.findUnique({ 8 where: { id: userId }, 9 include: { subscription: true } 10 }) 11 12 if (!user) throw new Error('User not found') 13 14 const plan = PLANS[planId] 15 if (!plan.priceId) throw new Error('Cannot subscribe to free plan') 16 17 // Get or create Stripe customer 18 let customerId = user.stripeCustomerId 19 20 if (!customerId) { 21 const customer = await stripe.customers.create({ 22 email: user.email, 23 metadata: { userId: user.id } 24 }) 25 customerId = customer.id 26 27 await prisma.user.update({ 28 where: { id: userId }, 29 data: { stripeCustomerId: customerId } 30 }) 31 } 32 33 // Create checkout session for subscription 34 const session = await stripe.checkout.sessions.create({ 35 customer: customerId, 36 mode: 'subscription', 37 payment_method_types: ['card'], 38 line_items: [{ price: plan.priceId, quantity: 1 }], 39 success_url: `${process.env.NEXT_PUBLIC_URL}/billing?success=true`, 40 cancel_url: `${process.env.NEXT_PUBLIC_URL}/billing?canceled=true`, 41 metadata: { userId, planId } 42 }) 43 44 return session.url 45}

Webhook Handler#

Process subscription events from Stripe.

1// app/api/webhooks/stripe/route.ts 2import { stripe } from '@/lib/stripe' 3import { prisma } from '@/lib/db' 4import { headers } from 'next/headers' 5import Stripe from 'stripe' 6 7export async function POST(request: Request) { 8 const body = await request.text() 9 const signature = 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) { 20 return Response.json({ error: 'Invalid signature' }, { status: 400 }) 21 } 22 23 switch (event.type) { 24 case 'customer.subscription.created': 25 case 'customer.subscription.updated': 26 await handleSubscriptionChange(event.data.object as Stripe.Subscription) 27 break 28 29 case 'customer.subscription.deleted': 30 await handleSubscriptionCanceled(event.data.object as Stripe.Subscription) 31 break 32 33 case 'invoice.payment_failed': 34 await handlePaymentFailed(event.data.object as Stripe.Invoice) 35 break 36 } 37 38 return Response.json({ received: true }) 39} 40 41async function handleSubscriptionChange(subscription: Stripe.Subscription) { 42 const customerId = subscription.customer as string 43 44 const user = await prisma.user.findFirst({ 45 where: { stripeCustomerId: customerId } 46 }) 47 48 if (!user) return 49 50 await prisma.subscription.upsert({ 51 where: { userId: user.id }, 52 create: { 53 userId: user.id, 54 stripeSubscriptionId: subscription.id, 55 stripePriceId: subscription.items.data[0].price.id, 56 status: subscription.status, 57 currentPeriodEnd: new Date(subscription.current_period_end * 1000) 58 }, 59 update: { 60 stripeSubscriptionId: subscription.id, 61 stripePriceId: subscription.items.data[0].price.id, 62 status: subscription.status, 63 currentPeriodEnd: new Date(subscription.current_period_end * 1000) 64 } 65 }) 66} 67 68async function handleSubscriptionCanceled(subscription: Stripe.Subscription) { 69 const customerId = subscription.customer as string 70 71 const user = await prisma.user.findFirst({ 72 where: { stripeCustomerId: customerId } 73 }) 74 75 if (!user) return 76 77 await prisma.subscription.update({ 78 where: { userId: user.id }, 79 data: { status: 'canceled' } 80 }) 81}

Get User Plan#

Determine a user's current plan.

1// lib/billing.ts 2export async function getUserPlan(userId: string): Promise<PlanId> { 3 const subscription = await prisma.subscription.findUnique({ 4 where: { userId } 5 }) 6 7 if (!subscription || subscription.status !== 'active') { 8 return 'free' 9 } 10 11 // Find plan by price ID 12 for (const [planId, plan] of Object.entries(PLANS)) { 13 if (plan.priceId === subscription.stripePriceId) { 14 return planId as PlanId 15 } 16 } 17 18 return 'free' 19} 20 21export async function checkLimit( 22 userId: string, 23 resource: keyof typeof PLANS.free.limits, 24 currentCount: number 25): Promise<boolean> { 26 const planId = await getUserPlan(userId) 27 const limit = PLANS[planId].limits[resource] 28 29 // -1 means unlimited 30 if (limit === -1) return true 31 32 return currentCount < limit 33}

Cancel Subscription#

Cancel at period end or immediately.

1// lib/billing.ts 2export async function cancelSubscription( 3 userId: string, 4 immediate: boolean = false 5) { 6 const subscription = await prisma.subscription.findUnique({ 7 where: { userId } 8 }) 9 10 if (!subscription?.stripeSubscriptionId) { 11 throw new Error('No active subscription') 12 } 13 14 if (immediate) { 15 await stripe.subscriptions.cancel(subscription.stripeSubscriptionId) 16 } else { 17 // Cancel at period end (user keeps access until then) 18 await stripe.subscriptions.update(subscription.stripeSubscriptionId, { 19 cancel_at_period_end: true 20 }) 21 } 22 23 await prisma.subscription.update({ 24 where: { userId }, 25 data: { cancelAtPeriodEnd: !immediate } 26 }) 27} 28 29export async function reactivateSubscription(userId: string) { 30 const subscription = await prisma.subscription.findUnique({ 31 where: { userId } 32 }) 33 34 if (!subscription?.stripeSubscriptionId) { 35 throw new Error('No subscription found') 36 } 37 38 await stripe.subscriptions.update(subscription.stripeSubscriptionId, { 39 cancel_at_period_end: false 40 }) 41 42 await prisma.subscription.update({ 43 where: { userId }, 44 data: { cancelAtPeriodEnd: false } 45 }) 46}

Change Plan#

Upgrade or downgrade subscription.

1// lib/billing.ts 2export async function changePlan(userId: string, newPlanId: PlanId) { 3 const subscription = await prisma.subscription.findUnique({ 4 where: { userId } 5 }) 6 7 if (!subscription?.stripeSubscriptionId) { 8 throw new Error('No active subscription') 9 } 10 11 const newPlan = PLANS[newPlanId] 12 if (!newPlan.priceId) { 13 throw new Error('Cannot switch to free plan, cancel instead') 14 } 15 16 const stripeSubscription = await stripe.subscriptions.retrieve( 17 subscription.stripeSubscriptionId 18 ) 19 20 await stripe.subscriptions.update(subscription.stripeSubscriptionId, { 21 items: [{ 22 id: stripeSubscription.items.data[0].id, 23 price: newPlan.priceId 24 }], 25 proration_behavior: 'create_prorations' 26 }) 27}

Subscription Status Component#

Display subscription status to users.

1// components/billing/SubscriptionStatus.tsx 2import { getUserPlan } from '@/lib/billing' 3import { prisma } from '@/lib/db' 4import { PLANS } from '@/lib/plans' 5import { format } from 'date-fns' 6 7export async function SubscriptionStatus({ userId }: { userId: string }) { 8 const planId = await getUserPlan(userId) 9 const plan = PLANS[planId] 10 11 const subscription = await prisma.subscription.findUnique({ 12 where: { userId } 13 }) 14 15 return ( 16 <div className="rounded-lg border p-6"> 17 <div className="mb-4 flex items-center justify-between"> 18 <div> 19 <p className="text-sm text-gray-500">Current Plan</p> 20 <p className="text-xl font-bold">{plan.name}</p> 21 </div> 22 {subscription?.status === 'active' && ( 23 <span className="rounded-full bg-green-100 px-3 py-1 text-sm text-green-800"> 24 Active 25 </span> 26 )} 27 </div> 28 29 {subscription?.currentPeriodEnd && ( 30 <p className="text-sm text-gray-600"> 31 {subscription.cancelAtPeriodEnd 32 ? `Access until ${format(subscription.currentPeriodEnd, 'MMM d, yyyy')}` 33 : `Renews on ${format(subscription.currentPeriodEnd, 'MMM d, yyyy')}`} 34 </p> 35 )} 36 </div> 37 ) 38}

Best Practices#

  1. Always use webhooks - Don't rely on redirect success URLs
  2. Handle failed payments - Send dunning emails, pause access gracefully
  3. Prorate plan changes - Use Stripe's proration for fair billing
  4. Grace periods - Give users time to update payment methods
  5. Clear plan limits - Show users what they can do on each plan