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-jsCheckout 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#
- Use Stripe-hosted checkout - It's faster, more secure, and always up-to-date
- Handle cancellations gracefully - Provide a way back to the product
- Verify payment server-side - Never trust client-side payment status
- Send confirmation emails - Always confirm successful payments
- Store order records - Create orders in your database after payment
Related Patterns#
- Stripe - Stripe setup and configuration
- Webhooks - Handle payment events
- Subscriptions - Subscription checkout