Skip to content

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-js
typescript
// .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/stripe

Summary

  1. Stripe Checkout - Hosted payment page (easiest)
  2. Webhooks - Sync Stripe events to your database
  3. Customer Portal - Let users manage subscriptions
  4. Check subscription - Gate features by plan
  5. Test mode - Use test API keys and cards

Next: Module 10: Hosting & Deployment →