Stripe Integration Patterns

Battle-tested patterns for Stripe payment integration in Next.js.

Overview#

Stripe is the standard for online payments. This pattern covers:

  • Stripe SDK setup
  • Client and server configuration
  • Customer management
  • Testing with Stripe CLI
  • Environment configuration

Prerequisites#

npm install stripe @stripe/stripe-js

Stripe Client Setup#

Configure the Stripe SDK for server-side use.

1// lib/stripe.ts 2import Stripe from 'stripe' 3 4export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 5 apiVersion: '2024-12-18.acacia', 6 typescript: true 7})

Client-Side Stripe#

Load Stripe.js for client-side components.

1// lib/stripe-client.ts 2import { loadStripe, Stripe } from '@stripe/stripe-js' 3 4let stripePromise: Promise<Stripe | null> 5 6export function getStripe() { 7 if (!stripePromise) { 8 stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) 9 } 10 return stripePromise 11}

Create Customer#

Create and manage Stripe customers.

1// lib/stripe-customers.ts 2import { stripe } from './stripe' 3import { prisma } from './db' 4 5export async function getOrCreateStripeCustomer(userId: string) { 6 const user = await prisma.user.findUnique({ 7 where: { id: userId } 8 }) 9 10 if (!user) { 11 throw new Error('User not found') 12 } 13 14 // Return existing customer 15 if (user.stripeCustomerId) { 16 return user.stripeCustomerId 17 } 18 19 // Create new customer 20 const customer = await stripe.customers.create({ 21 email: user.email, 22 name: user.name ?? undefined, 23 metadata: { 24 userId: user.id 25 } 26 }) 27 28 // Save customer ID to database 29 await prisma.user.update({ 30 where: { id: userId }, 31 data: { stripeCustomerId: customer.id } 32 }) 33 34 return customer.id 35} 36 37export async function updateStripeCustomer( 38 customerId: string, 39 data: { email?: string; name?: string } 40) { 41 return stripe.customers.update(customerId, { 42 email: data.email, 43 name: data.name 44 }) 45}

One-Time Payment Checkout#

Create a checkout session for one-time payments.

1// app/api/checkout/route.ts 2import { stripe } from '@/lib/stripe' 3import { auth } from '@/auth' 4import { getOrCreateStripeCustomer } from '@/lib/stripe-customers' 5import { NextResponse } from 'next/server' 6 7export async function POST(req: Request) { 8 const session = await auth() 9 10 if (!session?.user) { 11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 12 } 13 14 const { priceId, quantity = 1 } = await req.json() 15 16 try { 17 const customerId = await getOrCreateStripeCustomer(session.user.id) 18 19 const checkoutSession = await stripe.checkout.sessions.create({ 20 customer: customerId, 21 mode: 'payment', 22 payment_method_types: ['card'], 23 line_items: [{ price: priceId, quantity }], 24 success_url: `${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, 25 cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`, 26 metadata: { 27 userId: session.user.id 28 } 29 }) 30 31 return NextResponse.json({ url: checkoutSession.url }) 32 } catch (error) { 33 console.error('Checkout error:', error) 34 return NextResponse.json( 35 { error: 'Failed to create checkout session' }, 36 { status: 500 } 37 ) 38 } 39}

Checkout Button Component#

A reusable button that redirects to Stripe Checkout.

1// components/CheckoutButton.tsx 2'use client' 3 4import { useState } from 'react' 5 6interface Props { 7 priceId: string 8 mode?: 'payment' | 'subscription' 9 children: React.ReactNode 10 className?: string 11} 12 13export function CheckoutButton({ 14 priceId, 15 mode = 'payment', 16 children, 17 className 18}: Props) { 19 const [loading, setLoading] = useState(false) 20 21 const handleCheckout = async () => { 22 setLoading(true) 23 24 try { 25 const endpoint = mode === 'subscription' 26 ? '/api/subscribe' 27 : '/api/checkout' 28 29 const res = await fetch(endpoint, { 30 method: 'POST', 31 headers: { 'Content-Type': 'application/json' }, 32 body: JSON.stringify({ priceId }) 33 }) 34 35 const { url, error } = await res.json() 36 37 if (error) { 38 alert(error) 39 return 40 } 41 42 window.location.href = url 43 } catch (error) { 44 console.error('Checkout error:', error) 45 alert('Something went wrong') 46 } finally { 47 setLoading(false) 48 } 49 } 50 51 return ( 52 <button 53 onClick={handleCheckout} 54 disabled={loading} 55 className={className} 56 > 57 {loading ? 'Loading...' : children} 58 </button> 59 ) 60}

Customer Portal#

Allow customers to manage their billing.

1// app/api/billing/portal/route.ts 2import { stripe } from '@/lib/stripe' 3import { auth } from '@/auth' 4import { prisma } from '@/lib/db' 5import { NextResponse } from 'next/server' 6 7export async function POST() { 8 const session = await auth() 9 10 if (!session?.user) { 11 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 12 } 13 14 const user = await prisma.user.findUnique({ 15 where: { id: session.user.id } 16 }) 17 18 if (!user?.stripeCustomerId) { 19 return NextResponse.json( 20 { error: 'No billing account found' }, 21 { status: 400 } 22 ) 23 } 24 25 const portalSession = await stripe.billingPortal.sessions.create({ 26 customer: user.stripeCustomerId, 27 return_url: `${process.env.NEXT_PUBLIC_URL}/settings/billing` 28 }) 29 30 return NextResponse.json({ url: portalSession.url }) 31}

Environment Variables#

Required environment variables for Stripe.

1# .env.local 2STRIPE_SECRET_KEY=sk_test_... 3STRIPE_WEBHOOK_SECRET=whsec_... 4NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... 5 6# Price IDs from Stripe Dashboard 7STRIPE_PRO_PRICE_ID=price_... 8STRIPE_ENTERPRISE_PRICE_ID=price_...

Testing Locally#

Use Stripe CLI to forward webhooks.

1# Install Stripe CLI 2brew install stripe/stripe-cli/stripe 3 4# Login to Stripe 5stripe login 6 7# Forward webhooks to local server 8stripe listen --forward-to localhost:3000/api/webhooks/stripe 9 10# Trigger test events 11stripe trigger checkout.session.completed 12stripe trigger customer.subscription.updated

Test Card Numbers#

Use these test cards during development.

ScenarioCard Number
Success4242 4242 4242 4242
Decline4000 0000 0000 0002
3D Secure4000 0027 6000 3184
Insufficient Funds4000 0000 0000 9995

Best Practices#

  1. Always verify webhooks - Never trust client-side payment confirmation
  2. Store customer IDs - Link Stripe customers to your user records
  3. Use idempotency keys - Prevent duplicate charges on retries
  4. Handle errors gracefully - Show meaningful messages to users
  5. Log webhook events - Maintain an audit trail for debugging