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 stripeDefine 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#
- Always use webhooks - Don't rely on redirect success URLs
- Handle failed payments - Send dunning emails, pause access gracefully
- Prorate plan changes - Use Stripe's proration for fair billing
- Grace periods - Give users time to update payment methods
- Clear plan limits - Show users what they can do on each plan
Related Patterns#
- Stripe - Stripe setup
- Webhooks - Webhook handling
- Usage Billing - Metered billing