Module 9: Payments & Billing
Stripe is the industry standard. This module covers everything from basic charges to subscriptions.
Stripe Setup
bash
npm install stripe @stripe/stripe-jstypescript
// .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...typescript
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
})Subscription Model
Database Schema
prisma
model User {
id String @id @default(cuid())
email String @unique
stripeCustomerId String? @unique
stripeSubscriptionId String? @unique
stripePriceId String?
stripeCurrentPeriodEnd DateTime?
}Create Stripe Customer
typescript
// lib/stripe.ts
export async function createCustomer(email: string, userId: string) {
const customer = await stripe.customers.create({
email,
metadata: { userId },
})
await db.user.update({
where: { id: userId },
data: { stripeCustomerId: customer.id },
})
return customer
}Checkout Session
Create Checkout
typescript
// app/api/checkout/route.ts
import { auth } from '@clerk/nextjs/server'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST(req: Request) {
const { userId } = auth()
if (!userId) {
return new Response('Unauthorized', { status: 401 })
}
const { priceId } = await req.json()
const user = await db.user.findUnique({ where: { id: userId } })
// Get or create Stripe customer
let customerId = user?.stripeCustomerId
if (!customerId) {
const customer = await stripe.customers.create({
email: user?.email,
metadata: { userId },
})
customerId = customer.id
await db.user.update({
where: { id: userId },
data: { stripeCustomerId: customerId },
})
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,
metadata: { userId },
})
return Response.json({ url: session.url })
}Checkout Button
tsx
// components/checkout-button.tsx
'use client'
import { useState } from 'react'
interface CheckoutButtonProps {
priceId: string
}
export function CheckoutButton({ priceId }: CheckoutButtonProps) {
const [loading, setLoading] = useState(false)
const handleCheckout = async () => {
setLoading(true)
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ priceId }),
})
const { url } = await response.json()
window.location.href = url
}
return (
<button
onClick={handleCheckout}
disabled={loading}
className="bg-blue-600 text-white px-6 py-3 rounded-lg"
>
{loading ? 'Loading...' : 'Subscribe'}
</button>
)
}Webhooks (Critical!)
Webhooks sync Stripe events to your database:
typescript
// app/api/webhooks/stripe/route.ts
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST(req: Request) {
const body = await req.text()
const signature = headers().get('stripe-signature')!
let event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return new Response('Webhook signature verification failed', { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
await db.user.update({
where: { stripeCustomerId: session.customer as string },
data: {
stripeSubscriptionId: subscription.id,
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
})
break
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object
const subscription = await stripe.subscriptions.retrieve(
invoice.subscription as string
)
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
stripePriceId: subscription.items.data[0].price.id,
stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
})
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object
await db.user.update({
where: { stripeSubscriptionId: subscription.id },
data: {
stripeSubscriptionId: null,
stripePriceId: null,
stripeCurrentPeriodEnd: null,
},
})
break
}
}
return new Response('OK', { status: 200 })
}Customer Portal
Let users manage their subscription:
typescript
// app/api/portal/route.ts
import { auth } from '@clerk/nextjs/server'
import { stripe } from '@/lib/stripe'
import { db } from '@/lib/db'
export async function POST() {
const { userId } = auth()
if (!userId) {
return new Response('Unauthorized', { status: 401 })
}
const user = await db.user.findUnique({ where: { id: userId } })
if (!user?.stripeCustomerId) {
return new Response('No customer found', { status: 400 })
}
const session = await stripe.billingPortal.sessions.create({
customer: user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`,
})
return Response.json({ url: session.url })
}Check Subscription Status
typescript
// lib/subscription.ts
import { db } from '@/lib/db'
export async function getUserSubscription(userId: string) {
const user = await db.user.findUnique({
where: { id: userId },
select: {
stripePriceId: true,
stripeCurrentPeriodEnd: true,
stripeSubscriptionId: true,
},
})
if (!user) return { isSubscribed: false, plan: null }
const isSubscribed =
user.stripePriceId &&
user.stripeCurrentPeriodEnd &&
user.stripeCurrentPeriodEnd.getTime() > Date.now()
// Map price IDs to plan names
const plan = user.stripePriceId === 'price_xxx_pro' ? 'pro' : 'basic'
return { isSubscribed, plan }
}Protect Features
tsx
// app/dashboard/pro-feature/page.tsx
import { auth } from '@clerk/nextjs/server'
import { getUserSubscription } from '@/lib/subscription'
import { redirect } from 'next/navigation'
export default async function ProFeaturePage() {
const { userId } = auth()
const { isSubscribed, plan } = await getUserSubscription(userId!)
if (!isSubscribed || plan !== 'pro') {
redirect('/pricing')
}
return <div>Pro feature content...</div>
}Pricing Page
tsx
// app/pricing/page.tsx
const plans = [
{
name: 'Basic',
price: '$9',
priceId: 'price_xxx_basic',
features: ['Feature 1', 'Feature 2', 'Feature 3'],
},
{
name: 'Pro',
price: '$29',
priceId: 'price_xxx_pro',
features: ['Everything in Basic', 'Pro Feature 1', 'Pro Feature 2'],
popular: true,
},
]
export default function PricingPage() {
return (
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto py-24">
{plans.map((plan) => (
<div
key={plan.name}
className={`p-8 rounded-lg border ${
plan.popular ? 'border-blue-500 ring-2 ring-blue-500' : ''
}`}
>
<h3 className="text-xl font-bold">{plan.name}</h3>
<p className="text-4xl font-bold mt-4">
{plan.price}<span className="text-lg">/mo</span>
</p>
<ul className="mt-6 space-y-2">
{plan.features.map((feature) => (
<li key={feature}>✓ {feature}</li>
))}
</ul>
<CheckoutButton priceId={plan.priceId} />
</div>
))}
</div>
)
}Testing
Use Stripe test cards:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - Requires auth:
4000 0025 0000 3155
Test webhooks locally:
bash
stripe listen --forward-to localhost:3000/api/webhooks/stripeSummary
- Stripe Checkout - Hosted payment page (easiest)
- Webhooks - Sync Stripe events to your database
- Customer Portal - Let users manage subscriptions
- Check subscription - Gate features by plan
- Test mode - Use test API keys and cards