Stripe Checkout

One-time payments with Stripe Checkout.

Dependencies#

npm install stripe @stripe/stripe-js

Environment Variables#

# .env.local STRIPE_SECRET_KEY=sk_test_... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... STRIPE_WEBHOOK_SECRET=whsec_... NEXT_PUBLIC_APP_URL=http://localhost:3000

Stripe Client Setup#

1// lib/stripe.ts 2import Stripe from 'stripe'; 3 4export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 5 apiVersion: '2024-11-20.acacia', 6 typescript: true, 7});
1// lib/stripe-client.ts 2import { loadStripe } from '@stripe/stripe-js'; 3 4export const stripePromise = loadStripe( 5 process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY! 6);

Create Checkout Session#

1// app/api/checkout/route.ts 2import { auth } from '@clerk/nextjs/server'; 3import { NextRequest, NextResponse } from 'next/server'; 4import { stripe } from '@/lib/stripe'; 5import { prisma } from '@/lib/prisma'; 6 7export async function POST(request: NextRequest) { 8 const { userId } = await auth(); 9 if (!userId) { 10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 11 } 12 13 const body = await request.json(); 14 const { priceId, quantity = 1 } = body; 15 16 // Get or create Stripe customer 17 const user = await prisma.user.findUnique({ 18 where: { clerkId: userId }, 19 }); 20 21 let customerId = user?.stripeCustomerId; 22 23 if (!customerId) { 24 const customer = await stripe.customers.create({ 25 email: user?.email, 26 metadata: { userId }, 27 }); 28 customerId = customer.id; 29 30 await prisma.user.update({ 31 where: { clerkId: userId }, 32 data: { stripeCustomerId: customerId }, 33 }); 34 } 35 36 // Create checkout session 37 const session = await stripe.checkout.sessions.create({ 38 customer: customerId, 39 mode: 'payment', 40 payment_method_types: ['card'], 41 line_items: [ 42 { 43 price: priceId, 44 quantity, 45 }, 46 ], 47 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, 48 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/checkout/cancel`, 49 metadata: { 50 userId, 51 }, 52 }); 53 54 return NextResponse.json({ sessionId: session.id, url: session.url }); 55}

Checkout Button Component#

1// components/CheckoutButton.tsx 2'use client'; 3 4import { useState } from 'react'; 5import { stripePromise } from '@/lib/stripe-client'; 6 7interface CheckoutButtonProps { 8 priceId: string; 9 quantity?: number; 10 children: React.ReactNode; 11 className?: string; 12} 13 14export function CheckoutButton({ 15 priceId, 16 quantity = 1, 17 children, 18 className, 19}: CheckoutButtonProps) { 20 const [loading, setLoading] = useState(false); 21 22 const handleCheckout = async () => { 23 setLoading(true); 24 25 try { 26 const response = await fetch('/api/checkout', { 27 method: 'POST', 28 headers: { 'Content-Type': 'application/json' }, 29 body: JSON.stringify({ priceId, quantity }), 30 }); 31 32 const { sessionId, url } = await response.json(); 33 34 // Option 1: Redirect to Stripe hosted page 35 if (url) { 36 window.location.href = url; 37 return; 38 } 39 40 // Option 2: Use Stripe.js redirect 41 const stripe = await stripePromise; 42 const { error } = await stripe!.redirectToCheckout({ sessionId }); 43 44 if (error) { 45 console.error('Checkout error:', error); 46 } 47 } catch (error) { 48 console.error('Checkout failed:', error); 49 } finally { 50 setLoading(false); 51 } 52 }; 53 54 return ( 55 <button 56 onClick={handleCheckout} 57 disabled={loading} 58 className={className} 59 > 60 {loading ? 'Loading...' : children} 61 </button> 62 ); 63}

Product Display#

1// components/ProductCard.tsx 2import { CheckoutButton } from './CheckoutButton'; 3 4interface Product { 5 id: string; 6 name: string; 7 description: string; 8 price: number; 9 priceId: string; 10 image?: string; 11} 12 13export function ProductCard({ product }: { product: Product }) { 14 return ( 15 <div className="border rounded-xl p-6 bg-white dark:bg-gray-900"> 16 {product.image && ( 17 <img 18 src={product.image} 19 alt={product.name} 20 className="w-full h-48 object-cover rounded-lg mb-4" 21 /> 22 )} 23 <h3 className="text-xl font-semibold">{product.name}</h3> 24 <p className="text-gray-600 dark:text-gray-400 mt-2"> 25 {product.description} 26 </p> 27 <div className="mt-4 flex items-center justify-between"> 28 <span className="text-2xl font-bold"> 29 ${(product.price / 100).toFixed(2)} 30 </span> 31 <CheckoutButton 32 priceId={product.priceId} 33 className="px-6 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700" 34 > 35 Buy Now 36 </CheckoutButton> 37 </div> 38 </div> 39 ); 40}

Webhook Handler#

1// app/api/webhooks/stripe/route.ts 2import { NextRequest, NextResponse } from 'next/server'; 3import { stripe } from '@/lib/stripe'; 4import { prisma } from '@/lib/prisma'; 5import Stripe from 'stripe'; 6 7export async function POST(request: NextRequest) { 8 const body = await request.text(); 9 const signature = request.headers.get('stripe-signature')!; 10 11 let event: Stripe.Event; 12 13 try { 14 event = stripe.webhooks.constructEvent( 15 body, 16 signature, 17 process.env.STRIPE_WEBHOOK_SECRET! 18 ); 19 } catch (err: any) { 20 console.error('Webhook signature verification failed:', err.message); 21 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 }); 22 } 23 24 switch (event.type) { 25 case 'checkout.session.completed': { 26 const session = event.data.object as Stripe.Checkout.Session; 27 28 // Record the purchase 29 await prisma.purchase.create({ 30 data: { 31 stripeSessionId: session.id, 32 stripeCustomerId: session.customer as string, 33 userId: session.metadata?.userId!, 34 amount: session.amount_total!, 35 currency: session.currency!, 36 status: 'completed', 37 }, 38 }); 39 40 // Fulfill the order (e.g., grant access, send email) 41 await fulfillOrder(session); 42 break; 43 } 44 45 case 'payment_intent.payment_failed': { 46 const paymentIntent = event.data.object as Stripe.PaymentIntent; 47 console.error('Payment failed:', paymentIntent.id); 48 break; 49 } 50 } 51 52 return NextResponse.json({ received: true }); 53} 54 55async function fulfillOrder(session: Stripe.Checkout.Session) { 56 const userId = session.metadata?.userId; 57 if (!userId) return; 58 59 // Example: Grant product access 60 await prisma.user.update({ 61 where: { clerkId: userId }, 62 data: { 63 purchasedProducts: { 64 push: session.metadata?.productId, 65 }, 66 }, 67 }); 68 69 // Send confirmation email 70 // await sendPurchaseConfirmationEmail(userId, session); 71}

Success Page#

1// app/checkout/success/page.tsx 2import { stripe } from '@/lib/stripe'; 3import { redirect } from 'next/navigation'; 4import Link from 'next/link'; 5import { CheckCircle } from 'lucide-react'; 6 7export default async function SuccessPage({ 8 searchParams, 9}: { 10 searchParams: { session_id?: string }; 11}) { 12 if (!searchParams.session_id) { 13 redirect('/'); 14 } 15 16 const session = await stripe.checkout.sessions.retrieve( 17 searchParams.session_id 18 ); 19 20 if (session.payment_status !== 'paid') { 21 redirect('/checkout/cancel'); 22 } 23 24 return ( 25 <div className="min-h-screen flex items-center justify-center"> 26 <div className="text-center"> 27 <CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" /> 28 <h1 className="text-3xl font-bold mb-2">Payment Successful!</h1> 29 <p className="text-gray-600 mb-6"> 30 Thank you for your purchase. You will receive a confirmation email shortly. 31 </p> 32 <Link 33 href="/dashboard" 34 className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700" 35 > 36 Go to Dashboard 37 </Link> 38 </div> 39 </div> 40 ); 41}

Cancel Page#

1// app/checkout/cancel/page.tsx 2import Link from 'next/link'; 3import { XCircle } from 'lucide-react'; 4 5export default function CancelPage() { 6 return ( 7 <div className="min-h-screen flex items-center justify-center"> 8 <div className="text-center"> 9 <XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" /> 10 <h1 className="text-3xl font-bold mb-2">Payment Cancelled</h1> 11 <p className="text-gray-600 mb-6"> 12 Your payment was cancelled. No charges were made. 13 </p> 14 <Link 15 href="/pricing" 16 className="px-6 py-3 bg-brand-600 text-white rounded-lg hover:bg-brand-700" 17 > 18 Return to Pricing 19 </Link> 20 </div> 21 </div> 22 ); 23}

Database Schema#

1// prisma/schema.prisma 2model User { 3 id String @id @default(cuid()) 4 clerkId String @unique 5 email String 6 stripeCustomerId String? @unique 7 purchasedProducts String[] 8 9 purchases Purchase[] 10 11 @@index([stripeCustomerId]) 12} 13 14model Purchase { 15 id String @id @default(cuid()) 16 stripeSessionId String @unique 17 stripeCustomerId String 18 userId String 19 user User @relation(fields: [userId], references: [clerkId]) 20 amount Int 21 currency String 22 status String 23 24 createdAt DateTime @default(now()) 25 26 @@index([userId]) 27 @@index([stripeCustomerId]) 28}