Skip to main content
Docs

Build a custom checkout flow with an existing payment method

Warning

This guide is for users who want to build a custom user interface using the Clerk API. To use a prebuilt UI, use the Account Portal pages or prebuilt components.

Warning

This guide is using experimental APIs and subject to change while Clerk Billing is under Beta. To mitigate potential disruptions, we recommend pinning your SDK and clerk-js package versions.

This guide will walk you through how to build a custom user interface for a checkout flow that allows users to checkout with an existing payment method. For the custom flow that allows users to add a new payment method during checkout, see the dedicated guide.

Enable billing features

To use billing features, you first need to ensure they are enabled for your application. Follow the Billing documentation to enable them and setup your plans.

Checkout flow

To create a checkout session with an existing payment method, you must:

  1. Set up the checkout provider with plan details.
  2. Initialize the checkout session when the user is ready.
  3. Fetch and display the user's existing payment methods.
  4. Confirm the payment with the selected payment method.
  5. Complete the checkout process and redirect the user.

The following example:

  1. Uses the useCheckout() hook to initiate and manage the checkout session.
  2. Uses the usePaymentMethods() hook to fetch the user's existing payment methods.
  3. Assumes that you have already have a valid planId, which you can acquire in many ways:

This example is written for Next.js App Router but can be adapted for any React-based framework.

app/checkout/page.tsx
'use client'
import * as React from 'react'
import { SignedIn, ClerkLoaded } from '@clerk/nextjs'
import { CheckoutProvider, useCheckout, usePaymentMethods } from '@clerk/nextjs/experimental'
import { useMemo, useState } from 'react'

export default function CheckoutPage() {
  return (
    <CheckoutProvider planId="cplan_xxx" planPeriod="month">
      <ClerkLoaded>
        <SignedIn>
          <CustomCheckout />
        </SignedIn>
      </ClerkLoaded>
    </CheckoutProvider>
  )
}

function CustomCheckout() {
  const { checkout } = useCheckout()
  const { status } = checkout

  if (status === 'needs_initialization') {
    return <CheckoutInitialization />
  }

  return (
    <div className="checkout-container">
      <CheckoutSummary />
      <PaymentSection />
    </div>
  )
}

function CheckoutInitialization() {
  const { checkout } = useCheckout()
  const { start, status, fetchStatus } = checkout

  if (status !== 'needs_initialization') {
    return null
  }

  return (
    <button onClick={start} disabled={fetchStatus === 'fetching'} className="start-checkout-button">
      {fetchStatus === 'fetching' ? 'Initializing...' : 'Start Checkout'}
    </button>
  )
}

function PaymentSection() {
  const { checkout } = useCheckout()
  const { data, isLoading } = usePaymentMethods({
    for: 'user',
    pageSize: 20,
  })

  const { isConfirming, confirm, finalize, error } = checkout

  const [isProcessing, setIsProcessing] = useState(false)
  const [paymentMethodId, setPaymentMethodId] = useState<string | null>(null)

  const defaultMethod = useMemo(() => data?.find((method) => method.isDefault), [data])

  const submitSelectedMethod = async () => {
    const paymentSourceId = paymentMethodId || defaultMethod?.id
    if (isProcessing || !paymentSourceId) return
    setIsProcessing(true)

    try {
      // Confirm checkout with payment method
      await confirm({ paymentSourceId })
      // Complete checkout and redirect
      finalize({ redirectUrl: '/dashboard' })
    } catch (error) {
      console.error('Payment failed:', error)
    } finally {
      setIsProcessing(false)
    }
  }

  if (isLoading) {
    return <div>Loading...</div>
  }

  return (
    <>
      <select
        defaultValue={defaultMethod?.id}
        onChange={(e) => {
          const methodId = e.target.value
          const method = data?.find((method) => method.id === methodId)
          if (method) {
            setPaymentMethodId(method.id)
          }
        }}
      >
        {data?.map((method) => (
          <option key={method.id}>
            **** **** **** {method.last4} {method.cardType}
          </option>
        ))}
      </select>

      {error && <div>{error.message}</div>}

      <button type="button" disabled={isProcessing || isConfirming} onClick={submitSelectedMethod}>
        {isProcessing || isConfirming ? 'Processing...' : 'Complete Purchase'}
      </button>
    </>
  )
}

function CheckoutSummary() {
  const { checkout } = useCheckout()
  const { plan, totals } = checkout

  if (!plan) {
    return null
  }

  return (
    <div>
      <h2>Order Summary</h2>
      <span>{plan.name}</span>
      <span>
        {totals.totalDueNow.currencySymbol} {totals.totalDueNow.amountFormatted}
      </span>
    </div>
  )
}

Feedback

What did you think of this content?

Last updated on