Complete Guide to Stripe Payment Integration for SaaS Applications
Payment processing is the financial backbone of every SaaS business. Stripe has become the dominant payment processor for internet businesses, handling over a trillion dollars in payment volume annually. But integrating Stripe correctly — with subscription billing, webhook handling, proration, dunning, and customer portal — is one of the most technically challenging parts of building a SaaS.
This guide covers everything you need to know about Stripe integration for SaaS applications, with production-ready code examples for Next.js 14. Whether you are building a subscription billing boilerplate or adding payments to an existing app, these patterns will save you weeks of development.
Stripe Integration Architecture
A complete Stripe integration for SaaS involves these components:
- Stripe Checkout or Elements for payment collection
- Webhook endpoint for asynchronous event processing
- Customer portal for subscription management
- Database synchronization for subscription status
- Middleware for access control based on subscription
The Stripe Subscription Starter template includes all of this pre-configured with Next.js 14.
Setting Up Stripe SDK
// lib/stripe/server.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-11-20.acacia',
typescript: true,
})
// lib/stripe/client.ts
import { loadStripe } from '@stripe/stripe-js'
export const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
)Creating a Checkout Session
// app/api/create-checkout/route.ts
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function POST(req: Request) {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { priceId, mode = 'subscription' } = await req.json()
let customerId: string | undefined
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
})
if (user?.stripeCustomerId) {
customerId = user.stripeCustomerId
}
const checkout = await stripe.checkout.sessions.create({
customer: customerId,
customer_email: customerId ? undefined : session.user.email!,
mode: mode as 'subscription' | 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId: session.user.id },
subscription_data: mode === 'subscription' ? {
metadata: { userId: session.user.id },
} : undefined,
})
return NextResponse.json({ url: checkout.url })
}Webhook Handler with Idempotency
Webhook processing must be idempotent — processing the same event twice should produce the same result:
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe/server'
import { prisma } from '@/lib/prisma'
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(req: Request) {
const body = await req.text()
const signature = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch {
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const checkout = event.data.object as Stripe.Checkout.Session
const userId = checkout.metadata?.userId
if (!userId) break
if (checkout.mode === 'subscription') {
const subscription = await stripe.subscriptions.retrieve(
checkout.subscription as string
)
await prisma.user.update({
where: { id: userId },
data: {
stripeCustomerId: checkout.customer as string,
stripeSubscriptionId: subscription.id,
stripeSubscriptionStatus: subscription.status,
stripePriceId: subscription.items.data[0].price.id,
},
})
}
break
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice
const subscriptionId = invoice.subscription as string
if (!subscriptionId) break
const subscription = await stripe.subscriptions.retrieve(subscriptionId)
const userId = subscription.metadata?.userId
if (userId) {
await prisma.user.update({
where: { id: userId },
data: { stripeSubscriptionStatus: subscription.status },
})
}
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const userId = subscription.metadata?.userId
if (userId) {
await prisma.user.update({
where: { id: userId },
data: {
stripeSubscriptionStatus: subscription.status,
stripePriceId: subscription.items.data[0]?.price.id ?? null,
},
})
}
break
}
}
return NextResponse.json({ received: true })
}Customer Portal
Stripe's hosted customer portal lets users manage billing without building UI:
// app/api/portal/route.ts
import { NextResponse } from 'next/server'
import { stripe } from '@/lib/stripe/server'
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
export async function GET() {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { stripeCustomerId: true },
})
if (!user?.stripeCustomerId) {
return NextResponse.json({ error: 'No customer found' }, { status: 400 })
}
const portal = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
})
return NextResponse.json({ url: portal.url })
}Access Control Middleware
// lib/stripe/guard.ts
import { prisma } from '@/lib/prisma'
const VALID_STATUSES = ['active', 'trialing', 'past_due']
export async function requireSubscription(userId: string, priceId?: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { stripeSubscriptionStatus: true, stripePriceId: true },
})
if (!user || !VALID_STATUSES.includes(user.stripeSubscriptionStatus!)) {
return { allowed: false, reason: 'No active subscription' }
}
if (priceId && user.stripePriceId !== priceId) {
return { allowed: false, reason: 'Requires higher plan' }
}
return { allowed: true }
}The Bottom Line
Stripe integration is one of the most critical and complex parts of any SaaS application. A production-ready Stripe subscription template handles checkout, webhooks, customer portal, and access control so you never have to debug billing code in production. Focus on your product — let Stripe handle the payments.
Related Template
Try this production-ready starter kit to build your project faster.
Launch Your SaaS in Days
Production-ready boilerplate with auth, billing, team management, and a beautiful dashboard.
Auth & Users
Email, Google, GitHub login
Team Management
Invite & manage members
Dashboard
Analytics & metrics
Stripe Billing
Subscriptions & invoices
Simple, Transparent Pricing
Starter
$29/mo
- 3 projects
- 5 team members
- Basic analytics
- Email support
Pro
$79/mo
- Unlimited projects
- 20 team members
- Advanced analytics
- Priority support
- API access
Enterprise
$199/mo
- Everything in Pro
- Unlimited members
- SSO & SAML
- Dedicated support
- Custom integrations
Frequently Asked Questions
SaaS Starter Kit
Launch your SaaS in days, not months
Ready to Build?
Get started with our production-ready starter kits and ship your project faster.
Browse Starter Kits