← Back to articles

How to Add Stripe Payments to Next.js (2026)

Stripe is the standard for SaaS payments. This guide covers everything: creating products, checkout sessions, handling webhooks, customer portal, and managing subscriptions in a Next.js app.

Setup

npm install stripe @stripe/stripe-js
# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Stripe Client

// src/lib/stripe.ts
import Stripe from 'stripe'

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
})

Create Products in Stripe Dashboard

  1. Go to Stripe Dashboard → Products
  2. Create a product (e.g., "Pro Plan")
  3. Add prices:
    • Monthly: $19/month (recurring)
    • Yearly: $190/year (recurring)
  4. Copy the Price IDs (price_xxx)

Checkout (Subscriptions)

API Route

// src/app/api/stripe/checkout/route.ts
import { auth } from '@clerk/nextjs/server'
import { stripe } from '@/lib/stripe'

export async function POST(req: Request) {
  const { userId } = auth()
  if (!userId) return new Response('Unauthorized', { status: 401 })

  const { priceId } = await req.json()

  // Get or create Stripe customer
  const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
  
  let customerId = user?.stripeCustomerId
  if (!customerId) {
    const customer = await stripe.customers.create({
      metadata: { userId },
      email: user?.email,
    })
    customerId = customer.id
    await db.update(users).set({ stripeCustomerId: customerId }).where(eq(users.id, userId))
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: 'subscription',
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?upgraded=true`,
    cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
    subscription_data: {
      trial_period_days: 14, // Optional: 14-day trial
    },
  })

  return Response.json({ url: session.url })
}

Pricing Page Component

'use client'

export function PricingCard({ name, price, priceId, features, popular }) {
  const handleCheckout = async () => {
    const res = await fetch('/api/stripe/checkout', {
      method: 'POST',
      body: JSON.stringify({ priceId }),
    })
    const { url } = await res.json()
    window.location.href = url
  }

  return (
    <div className={`border rounded-lg p-6 ${popular ? 'border-blue-500 ring-2 ring-blue-500' : ''}`}>
      <h3 className="text-xl font-bold">{name}</h3>
      <p className="text-3xl font-bold mt-2">{price}</p>
      <ul className="mt-4 space-y-2">
        {features.map(f => <li key={f}>✓ {f}</li>)}
      </ul>
      {priceId && (
        <button onClick={handleCheckout} className="mt-6 w-full bg-black text-white py-2 rounded">
          Get Started
        </button>
      )}
    </div>
  )
}

Webhook Handler

This is the most important part. Stripe sends events to your webhook endpoint:

// src/app/api/webhooks/stripe/route.ts
import { stripe } from '@/lib/stripe'
import { headers } from 'next/headers'

export async function POST(req: Request) {
  const body = await req.text()
  const signature = headers().get('stripe-signature')!

  let event: Stripe.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 as Stripe.Checkout.Session
      // Activate subscription
      await db.update(users)
        .set({ plan: 'pro' })
        .where(eq(users.stripeCustomerId, session.customer as string))
      break
    }
    
    case 'invoice.payment_succeeded': {
      // Recurring payment succeeded — keep plan active
      const invoice = event.data.object as Stripe.Invoice
      await db.update(users)
        .set({ plan: 'pro' })
        .where(eq(users.stripeCustomerId, invoice.customer as string))
      break
    }

    case 'invoice.payment_failed': {
      // Payment failed — notify user, maybe downgrade after grace period
      const invoice = event.data.object as Stripe.Invoice
      // Send email: "Your payment failed, please update your card"
      break
    }

    case 'customer.subscription.deleted': {
      // Subscription cancelled
      const sub = event.data.object as Stripe.Subscription
      await db.update(users)
        .set({ plan: 'free' })
        .where(eq(users.stripeCustomerId, sub.customer as string))
      break
    }
  }

  return new Response('OK')
}

Customer Portal (Manage Subscriptions)

Let users manage their own billing — upgrade, downgrade, cancel, update payment method:

// src/app/api/stripe/portal/route.ts
import { auth } from '@clerk/nextjs/server'
import { stripe } from '@/lib/stripe'

export async function POST() {
  const { userId } = auth()
  if (!userId) return new Response('Unauthorized', { status: 401 })

  const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
  
  if (!user?.stripeCustomerId) {
    return new Response('No subscription', { status: 400 })
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: user.stripeCustomerId,
    return_url: `${process.env.NEXT_PUBLIC_URL}/dashboard/settings`,
  })

  return Response.json({ url: session.url })
}

One-Time Payments

For credits, lifetime deals, or one-time features:

const session = await stripe.checkout.sessions.create({
  mode: 'payment', // Not 'subscription'
  line_items: [{
    price_data: {
      currency: 'usd',
      product_data: { name: '100 Credits' },
      unit_amount: 999, // $9.99 in cents
    },
    quantity: 1,
  }],
  success_url: `${process.env.NEXT_PUBLIC_URL}/dashboard?purchased=credits`,
  cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
})

Testing Locally

# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Forward webhooks to your local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe

Use test card numbers:

  • Success: 4242 4242 4242 4242
  • Decline: 4000 0000 0000 0002
  • 3D Secure: 4000 0025 0000 3155

Common Patterns

Check Subscription Status

export async function getUserPlan(userId: string) {
  const user = await db.query.users.findFirst({ where: eq(users.id, userId) })
  return user?.plan || 'free'
}

// In a Server Component
const plan = await getUserPlan(userId)
if (plan === 'free') return <UpgradePrompt />

Metered Billing (Usage-Based)

// Report usage to Stripe
await stripe.subscriptionItems.createUsageRecord(subscriptionItemId, {
  quantity: apiCallCount,
  timestamp: Math.floor(Date.now() / 1000),
  action: 'increment',
})

FAQ

Should I use Stripe Checkout or Stripe Elements?

Stripe Checkout (redirect to Stripe-hosted page) for 90% of SaaS apps. Stripe Elements (embedded payment form) when you need full design control.

How do I handle failed payments?

Listen for invoice.payment_failed webhook. Send the user an email. Stripe retries automatically (Smart Retries). Downgrade after 3 failed attempts.

Stripe vs Lemon Squeezy?

Stripe: more control, better API, you handle tax. Lemon Squeezy: handles tax, simpler setup, higher fees. Use Lemon Squeezy if you don't want to deal with sales tax.

Bottom Line

Stripe Checkout + webhooks + Customer Portal = complete billing in a few hours. Don't build custom payment forms. Use Stripe's hosted pages and focus on your product.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.