Checkout Flow Patterns

Build seamless checkout experiences with Stripe Checkout.

Overview#

Checkout flows convert visitors to customers. This pattern covers:

  • Hosted checkout sessions
  • Embedded checkout
  • Custom checkout forms
  • Success and cancel pages
  • Order fulfillment

Prerequisites#

npm install stripe @stripe/stripe-js @stripe/react-stripe-js

Checkout Session Creation#

Create a checkout session with line items.

1// lib/checkout.ts 2import { stripe } from '@/lib/stripe' 3import { prisma } from '@/lib/db' 4 5interface CheckoutOptions { 6 userId: string 7 priceId: string 8 mode?: 'subscription' | 'payment' 9 quantity?: number 10 successUrl?: string 11 cancelUrl?: string 12 metadata?: Record<string, string> 13} 14 15export async function createCheckoutSession({ 16 userId, 17 priceId, 18 mode = 'subscription', 19 quantity = 1, 20 successUrl, 21 cancelUrl, 22 metadata = {} 23}: CheckoutOptions) { 24 const user = await prisma.user.findUnique({ 25 where: { id: userId } 26 }) 27 28 if (!user) throw new Error('User not found') 29 30 // Get or create Stripe customer 31 let customerId = user.stripeCustomerId 32 33 if (!customerId) { 34 const customer = await stripe.customers.create({ 35 email: user.email, 36 name: user.name ?? undefined, 37 metadata: { userId } 38 }) 39 customerId = customer.id 40 41 await prisma.user.update({ 42 where: { id: userId }, 43 data: { stripeCustomerId: customerId } 44 }) 45 } 46 47 const session = await stripe.checkout.sessions.create({ 48 customer: customerId, 49 mode, 50 payment_method_types: ['card'], 51 line_items: [{ price: priceId, quantity }], 52 success_url: successUrl ?? `${process.env.NEXT_PUBLIC_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, 53 cancel_url: cancelUrl ?? `${process.env.NEXT_PUBLIC_URL}/checkout/canceled`, 54 allow_promotion_codes: true, 55 billing_address_collection: 'auto', 56 tax_id_collection: { enabled: true }, 57 metadata: { userId, ...metadata } 58 }) 59 60 return session 61}

Checkout API Route#

API endpoint to create checkout sessions.

1// app/api/checkout/route.ts 2import { auth } from '@/auth' 3import { createCheckoutSession } from '@/lib/checkout' 4import { NextResponse } from 'next/server' 5 6export async function POST(request: Request) { 7 const session = await auth() 8 9 if (!session?.user) { 10 return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) 11 } 12 13 const { priceId, mode, quantity } = await request.json() 14 15 if (!priceId) { 16 return NextResponse.json({ error: 'Price ID required' }, { status: 400 }) 17 } 18 19 try { 20 const checkoutSession = await createCheckoutSession({ 21 userId: session.user.id, 22 priceId, 23 mode, 24 quantity 25 }) 26 27 return NextResponse.json({ url: checkoutSession.url }) 28 } catch (error) { 29 console.error('Checkout error:', error) 30 return NextResponse.json( 31 { error: 'Failed to create checkout' }, 32 { status: 500 } 33 ) 34 } 35}

Checkout Button Component#

Trigger checkout from any page.

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

Success Page#

Handle successful checkout completion.

1// app/checkout/success/page.tsx 2import { stripe } from '@/lib/stripe' 3import { redirect } from 'next/navigation' 4import { CheckCircle } from 'lucide-react' 5import Link from 'next/link' 6 7interface Props { 8 searchParams: { session_id?: string } 9} 10 11export default async function CheckoutSuccessPage({ searchParams }: Props) { 12 const sessionId = searchParams.session_id 13 14 if (!sessionId) { 15 redirect('/') 16 } 17 18 const session = await stripe.checkout.sessions.retrieve(sessionId, { 19 expand: ['subscription', 'customer', 'line_items'] 20 }) 21 22 if (session.payment_status !== 'paid') { 23 redirect('/checkout/canceled') 24 } 25 26 const isSubscription = session.mode === 'subscription' 27 28 return ( 29 <div className="mx-auto max-w-md py-16 text-center"> 30 <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-green-100"> 31 <CheckCircle className="h-8 w-8 text-green-600" /> 32 </div> 33 34 <h1 className="mb-2 text-2xl font-bold"> 35 {isSubscription ? 'Subscription Active!' : 'Payment Successful!'} 36 </h1> 37 38 <p className="mb-8 text-gray-600"> 39 {isSubscription 40 ? 'Thank you for subscribing. Your account has been upgraded.' 41 : 'Thank you for your purchase. You will receive a confirmation email shortly.'} 42 </p> 43 44 <div className="space-y-3"> 45 <Link 46 href="/dashboard" 47 className="block rounded-lg bg-black px-6 py-3 text-white hover:bg-gray-800" 48 > 49 Go to Dashboard 50 </Link> 51 <Link 52 href="/settings/billing" 53 className="block text-sm text-gray-600 hover:text-gray-900" 54 > 55 View billing details 56 </Link> 57 </div> 58 </div> 59 ) 60}

Embedded Checkout#

Embed Stripe Checkout directly in your page.

1// components/checkout/EmbeddedCheckout.tsx 2'use client' 3 4import { useState, useEffect } from 'react' 5import { loadStripe } from '@stripe/stripe-js' 6import { 7 EmbeddedCheckout, 8 EmbeddedCheckoutProvider 9} from '@stripe/react-stripe-js' 10 11const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!) 12 13interface Props { 14 priceId: string 15} 16 17export function CheckoutEmbed({ priceId }: Props) { 18 const [clientSecret, setClientSecret] = useState('') 19 20 useEffect(() => { 21 fetch('/api/checkout/embedded', { 22 method: 'POST', 23 headers: { 'Content-Type': 'application/json' }, 24 body: JSON.stringify({ priceId }) 25 }) 26 .then(res => res.json()) 27 .then(data => setClientSecret(data.clientSecret)) 28 }, [priceId]) 29 30 if (!clientSecret) { 31 return ( 32 <div className="flex h-96 items-center justify-center"> 33 <div className="h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-black" /> 34 </div> 35 ) 36 } 37 38 return ( 39 <EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}> 40 <EmbeddedCheckout /> 41 </EmbeddedCheckoutProvider> 42 ) 43} 44 45// API route for embedded checkout 46// app/api/checkout/embedded/route.ts 47export async function POST(request: Request) { 48 const session = await auth() 49 const { priceId } = await request.json() 50 51 const customerId = await getOrCreateStripeCustomer(session.user.id) 52 53 const checkoutSession = await stripe.checkout.sessions.create({ 54 ui_mode: 'embedded', 55 customer: customerId, 56 mode: 'subscription', 57 line_items: [{ price: priceId, quantity: 1 }], 58 return_url: `${process.env.NEXT_PUBLIC_URL}/checkout/return?session_id={CHECKOUT_SESSION_ID}` 59 }) 60 61 return Response.json({ clientSecret: checkoutSession.client_secret }) 62}

Dynamic Pricing#

Create checkout with custom pricing.

1// lib/checkout.ts 2export async function createPaymentSession({ 3 userId, 4 amount, 5 description, 6 metadata = {} 7}: { 8 userId: string 9 amount: number // in cents 10 description: string 11 metadata?: Record<string, string> 12}) { 13 const user = await prisma.user.findUnique({ where: { id: userId } }) 14 15 const session = await stripe.checkout.sessions.create({ 16 customer: user?.stripeCustomerId ?? undefined, 17 customer_email: !user?.stripeCustomerId ? user?.email : undefined, 18 mode: 'payment', 19 payment_method_types: ['card'], 20 line_items: [{ 21 price_data: { 22 currency: 'usd', 23 product_data: { 24 name: description 25 }, 26 unit_amount: amount 27 }, 28 quantity: 1 29 }], 30 success_url: `${process.env.NEXT_PUBLIC_URL}/payment/success`, 31 cancel_url: `${process.env.NEXT_PUBLIC_URL}/payment/canceled`, 32 metadata: { userId, ...metadata } 33 }) 34 35 return session 36}

Best Practices#

  1. Use Stripe-hosted checkout - It's faster, more secure, and always up-to-date
  2. Handle cancellations gracefully - Provide a way back to the product
  3. Verify payment server-side - Never trust client-side payment status
  4. Send confirmation emails - Always confirm successful payments
  5. Store order records - Create orders in your database after payment