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-jsStripe 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.updatedTest Card Numbers#
Use these test cards during development.
| Scenario | Card Number |
|---|---|
| Success | 4242 4242 4242 4242 |
| Decline | 4000 0000 0000 0002 |
| 3D Secure | 4000 0027 6000 3184 |
| Insufficient Funds | 4000 0000 0000 9995 |
Best Practices#
- Always verify webhooks - Never trust client-side payment confirmation
- Store customer IDs - Link Stripe customers to your user records
- Use idempotency keys - Prevent duplicate charges on retries
- Handle errors gracefully - Show meaningful messages to users
- Log webhook events - Maintain an audit trail for debugging
Related Patterns#
- Checkout - Checkout flow patterns
- Subscriptions - Subscription management
- Webhooks - Webhook handling