Use Stripe's Payment Element in the Next.js Starter Storefront

In this tutorial, you'll learn how to customize the Next.js Starter Storefront to use Stripe's Payment Element.

By default, the Next.js Starter Storefront comes with a basic Stripe card payment integration. However, you can replace it with Stripe's Payment Element instead.

By using the Payment Element, you can offer a unified payment experience that supports various payment methods, including credit cards, PayPal, iDeal, and more, all within a single component.

Summary#

By following this tutorial, you'll learn how to:

  • Set up a Medusa application with the Stripe Module Provider.
  • Customize the Next.js Starter Storefront to use Stripe's Payment Element.

Step 1: Set Up Medusa Project#

In this step, you'll set up a Medusa application and configure the Stripe Module Provider. You can skip this step if you already have a Medusa application running with the Stripe Module Provider configured.

a. Install Medusa Application#

To install the Medusa application, run the following command:

Terminal
npx create-medusa-app@latest

You'll first be asked for the project's name. Then, when you're asked whether you want to install the Next.js Starter Storefront, choose Y for yes.

Afterwards, the installation process will start, which will install the Medusa application in a directory with your project's name, and the Next.js Starter Storefront in a directory with the {project-name}-storefront name.

Why is the storefront installed separately? The Medusa application is composed of a headless Node.js server and an admin dashboard. The storefront is installed or custom-built separately and connects to the Medusa application through its REST endpoints, called API routes. Learn more about Medusa's architecture in this documentation.

Once the installation finishes successfully, the Medusa Admin dashboard will open with a form to create a new user. Enter the user's credentials and submit the form.

Afterwards, you can log in with the new user and explore the dashboard. The Next.js Starter Storefront is also running at http://localhost:8000.

Ran to Errors? Check out the troubleshooting guides for help.

b. Configure Stripe Module Provider#

Next, you'll configure the Stripe Module Provider in your Medusa application. The Stripe Module Provider allows you to accept payments through Stripe in your Medusa application.

The Stripe Module Provider is installed by default in your application. To use it, add it to the array of providers passed to the Payment Module in medusa-config.ts:

Medusa Application
medusa-config.ts
1module.exports = defineConfig({2  // ...3  modules: [4    {5      resolve: "@medusajs/medusa/payment",6      options: {7        providers: [8          {9            resolve: "@medusajs/medusa/payment-stripe",10            id: "stripe",11            options: {12              apiKey: process.env.STRIPE_API_KEY,13            },14          },15        ],16      },17    },18  ],19})

For more details about other available options and the webhook URLs that Medusa provides, refer to the Stripe Module Provider documentation.

c. Set Environment Variables#

Next, make sure to add the necessary environment variables for the above options in .env in your Medusa application:

Medusa Application
Terminal
STRIPE_API_KEY=<YOUR_STRIPE_API_KEY>

Where <YOUR_STRIPE_API_KEY> is your Stripe Secret API Key.

You also need to add the Stripe Publishable API Key in the Next.js Starter Storefront's environment variables:

Storefront
Terminal
NEXT_PUBLIC_STRIPE_KEY=<YOUR_STRIPE_PUBLISHABLE_API_KEY>

Where <YOUR_STRIPE_PUBLISHABLE_API_KEY> is your Stripe Publishable API Key.

d. Enable Stripe in a Region#

Finally, you need to add Stripe as a payment provider to one or more regions in your Medusa Admin. This will allow customers to use Stripe as a payment method during checkout.

To do that:

  1. Log in to your Medusa Admin dashboard at http://localhost:9000/app.
  2. Go to Settings -> Regions.
  3. Select a region you want to enable Stripe for (or create a new one).
  4. Click the icon on the upper right corner.
  5. Choose "Edit" from the dropdown menu.
  6. In the Payment Providers field, select “Stripe (STRIPE)”
  7. Click the Save button.

Do this for all regions you want to enable Stripe for.


Step 2: Update Payment Step in Checkout#

You'll start customizing the Next.js Starter Storefront by updating the payment step in the checkout process. By default, the payment step is implemented to show all available payment methods in the region the customer is in, and allows the customer to select one of them.

In this step, you'll replace the current payment method selection with Stripe's Payment Element. You'll no longer need to handle different payment methods separately, as the Payment Element will automatically adapt to the available methods configured in your Stripe account.

a. Update the Stripe SDKs#

To ensure you have the latest Stripe packages, update the @stripe/react-stripe-js and @stripe/stripe-js packages in your Next.js Starter Storefront:

b. Update the Payment Component#

The Payment component in src/modules/checkout/components/payment/index.tsx shows the payment step in checkout. It handles the payment method selection and submission. You'll update this component first to use the Payment Element.

Note: The full final code is available at the end of the section.

First, add the following imports at the top of the file:

src/modules/checkout/components/payment/index.tsx
1// ...other imports2import { useContext } from "react"3import { PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"4import { StripePaymentElementChangeEvent } from "@stripe/stripe-js"5import { StripeContext } from "../payment-wrapper/stripe-wrapper"

You import components from the Stripe SDKs, and the StripeContext created in the StripeWrapper component. This context will allow you to check whether Stripe is ready to be used.

Next, in the Payment component, replace the existing state variables with the following:

src/modules/checkout/components/payment/index.tsx
1const [isLoading, setIsLoading] = useState(false)2const [error, setError] = useState<string | null>(null)3const [stripeComplete, setStripeComplete] = useState(false)4const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>("")5
6const stripeReady = useContext(StripeContext)7const stripe = stripeReady ? useStripe() : null8const elements = stripeReady ? useElements() : null

You define the following variables:

  • isLoading: A boolean that indicates whether the payment is being processed.
  • error: A string that holds any error message related to the payment.
  • stripeComplete: A boolean that indicates whether operations with the Stripe Payment Element are complete. When this is enabled, the customer can proceed to the "Review" checkout step.
  • selectedPaymentMethod: A string that holds the currently selected payment method in the Payment Element. For example, card or eps.
  • stripeReady: A boolean that indicates whether Stripe is ready to be used, fetched from the StripeContext.
    • This context is defined in src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx and it wraps the checkout page in Stripe's Elements component, which initializes the Stripe SDKs.
  • stripe: The Stripe instance, which is used to interact with the Stripe API. It's only initialized if stripeReady from the Stripe context is true.
  • elements: The Stripe Elements instance, which is used to manage the Payment Element. It's also only initialized if stripeReady is true.

Next, add the following function to the Payment component to handle changes in the Payment Element:

src/modules/checkout/components/payment/index.tsx
1const handlePaymentElementChange = async (2  event: StripePaymentElementChangeEvent3) => {4  // Catches the selected payment method and sets it to state5  if (event.value.type) {6    setSelectedPaymentMethod(event.value.type)7  }8  9  // Sets stripeComplete on form completion10  setStripeComplete(event.complete)11
12  // Clears any errors on successful completion13  if (event.complete) {14    setError(null)15  }16}

This function will be called on every change in Stripe's Payment Element, such as when the customer selects a payment method.

In the function, you update the selectedPaymentMethod state with the type of payment method selected by the customer, set stripeComplete to true when the payment element is complete, and clear any error messages when the payment element is complete.

Then, to customize the payment step's submission, replace the handleSubmit function with the following:

src/modules/checkout/components/payment/index.tsx
1const handleSubmit = async () => {2  setIsLoading(true)3  setError(null)4
5  try {6    // Check if the necessary context is ready7    if (!stripe || !elements) {8      setError("Payment processing not ready. Please try again.")9      return10    }11
12    // Submit the payment method details13    await elements.submit().catch((err) => {14      console.error(err)15      setError(err.message || "An error occurred with the payment")16      return17    })18
19    // Navigate to the final checkout step20    router.push(pathname + "?" + createQueryString("step", "review"), {21      scroll: false,22    })23  } catch (err: any) {24    setError(err.message)25  } finally {26    setIsLoading(false)27  }28}

In this function, you use the elements.submit() method to submit the payment method details entered by the customer in the Payment Element. This doesn't actually confirm the payment yet; it just prepares the payment method for confirmation.

Once the payment method is submitted successfully, you navigate the customer to the Review step of the checkout process.

Next, to make sure a payment session is initialized when the customer reaches the payment step, add the following to the Payment component:

src/modules/checkout/components/payment/index.tsx
1const initStripe = async () => {2  try {3    await initiatePaymentSession(cart, {4      // TODO: change the provider ID if using a different ID in medusa-config.ts5      provider_id: "pp_stripe_stripe",6    })7  } catch (err) {8    console.error("Failed to initialize Stripe session:", err)9    setError("Failed to initialize payment. Please try again.")10  }11}12
13useEffect(() => {14  if (!activeSession && isOpen) {15    initStripe()16  }17}, [cart, isOpen, activeSession])

You add an initStripe function that initiates a payment session in the Medusa server. Notice that you set the provider ID to pp_stripe_stripe, which is the ID of the Stripe payment provider in your Medusa application if the ID in medusa-config.ts is stripe.

If you used a different ID, change the stripe in the middle accordingly. For example, if you set the ID to payment, you would set the provider_id to pp_payment_stripe.

You also add a useEffect hook that calls initStripe when the payment step is opened and there is no active payment session.

You'll now update the component's return statement to render the Payment Element.

First, find the following lines in the return statement:

src/modules/checkout/components/payment/index.tsx
1{!paidByGiftcard && availablePaymentMethods?.length && (2  <>3    <RadioGroup4      value={selectedPaymentMethod}5      onChange={(value: string) => setPaymentMethod(value)}6    >7      {/* ... */}8    </RadioGroup>9  </>10)}

And replace them with the following:

src/modules/checkout/components/payment/index.tsx
1{!paidByGiftcard &&2  availablePaymentMethods?.length &&3  stripeReady && (4    <div className="mt-5 transition-all duration-150 ease-in-out">5      <PaymentElement6        onChange={handlePaymentElementChange}7        options={{8          layout: "accordion",9        }}10      />11    </div>12  )13}

You replace the radio group with the payment methods available in Medusa with the PaymentElement component from the Stripe SDK. You pass it the following props:

  • onChange: A callback function that is called when the payment element's state changes. You pass the handlePaymentElementChange function you defined earlier.
  • options: An object that contains options for the payment element, such as the layout. Refer to Stripe's documentation for other options available.

Next, find the button rendered afterward and replace it with the following:

src/modules/checkout/components/payment/index.tsx
1<Button2  size="large"3  className="mt-6"4  onClick={handleSubmit}5  isLoading={isLoading}6  disabled={7    !stripeComplete ||8    !stripe ||9    !elements ||10    (!selectedPaymentMethod && !paidByGiftcard)11  }12  data-testid="submit-payment-button"13>14  Continue to review15</Button>

You update the button's disabled condition and text.

After that, to ensure the customer can only proceed to the review step if a payment method is selected, find the following lines in the return statement:

src/modules/checkout/components/payment/index.tsx
1{cart && paymentReady && activeSession ? (2  // rest of code...3)}

And replace them with the following:

src/modules/checkout/components/payment/index.tsx
1{cart && paymentReady && activeSession && selectedPaymentMethod ? (2  // rest of code...3)}

You update the condition to also check if a payment method is selected within the Payment Element.

Finally, find the following lines in the return statement:

src/modules/checkout/components/payment/index.tsx
1<Text>2  {isStripeFunc(selectedPaymentMethod) && cardBrand3    ? cardBrand4    : "Another step will appear"}5</Text>

And replace them with the following:

Code
<Text>Another step may appear</Text>

You show the same text for all payment methods since they're handled by Stripe's Payment Element.

You've now finished customizing the Payment step to show the Stripe Payment Element. This component will show the payment methods configured in your Stripe account.

Note: Feel free to remove unused imports, variables, and functions in the file.
Full updated code for src/modules/checkout/components/payment/index.tsx

c. Add Icons and Titles for Payment Methods#

After a customer enters their payment details and proceeds to the Review step, the payment method is displayed in the collapsed Payment step.

To ensure the correct icon and title are shown for your payment methods configured through Stripe, you can add them in the paymentInfoMap object defined in src/lib/constants.tsx.

For example:

src/lib/constants.tsx
1export const paymentInfoMap: Record<2  string,3  { title: string; icon: React.JSX.Element }4> = {5  // ...6  card: {7    title: "Credit card",8    icon: <CreditCard />,9  },10  paypal: {11    title: "PayPal",12    icon: <PayPal />,13  },14}

For every payment method you want to customize its display, add an entry in the paymentInfoMap object. The key should match the type enum in Stripe's Payment Element, and the value is an object with the following properties:

  • title: The title to display for the payment method.
  • icon: A JSX element representing the icon for the payment method. You can use icons from Medusa UI or custom icons.

Test it out#

Before you test out the payment checkout step, make sure you have the necessary payment methods configured in Stripe.

Then, follow these steps to test the payment checkout step in your Next.js Starter Storefront:

  1. Start the Medusa application with the following command:
  1. Start the Next.js Starter Storefront with the following command:
  1. Open the storefront at http://localhost:8000 in your browser.
  2. Go to Menu -> Store, choose a product, and add it to the cart.
  3. Proceed to checkout by clicking the cart icon in the top right corner, then click "Go to checkout".
  4. Complete the Address and Delivery steps that are before the Payment step.
  5. Once you reach the Payment step, your Stripe Payment Element should appear and list the different payment methods you’ve enabled in your Stripe account.

At this point, you can proceed to the Review step, but you can't confirm the payment with Stripe and place an order in Medusa. You'll customize the payment button in the next step to handle the payment confirmation with Stripe.

The Stripe Payment Element allows you to pay with different payment methods like PayPal


Step 3: Update the Payment Button#

Next, you'll update the payment button in the Review step of the checkout process. This button is used to confirm the payment with Stripe, then place the order in Medusa.

In this step, you'll customize the PaymentButton component in src/modules/checkout/components/payment-button/index.tsx to support confirming the payment with Stripe's Payment Element.

Note: The full final code is available at the end of the section.

Start by adding the following imports at the top of the file:

src/modules/checkout/components/payment-button/index.tsx
1// ...other imports2import { useEffect } from "react"3import { useParams, usePathname, useRouter } from "next/navigation"

Then, in the StripePaymentButton component, add the following variables:

src/modules/checkout/components/payment-button/index.tsx
1const { countryCode } = useParams()2const router = useRouter()3const pathname = usePathname()4const paymentSession = cart.payment_collection?.payment_sessions?.find(5  // TODO change the provider_id if using a different ID in medusa-config.ts6  (session) => session.provider_id === "pp_stripe_stripe"7)

You define the following variables:

  • countryCode: The country code of the customer's region, fetched from the URL. You'll use this later to create Stripe's redirect URL.
  • router: The Next.js router instance. You'll use this to redirect back to the payment step if necessary.
  • pathname: The current pathname. You'll use this when redirecting back to the payment step if necessary.
  • paymentSession: The Medusa payment session for the Stripe payment provider. This is used to get the clientSecret needed to confirm the payment.
    • Notice that the provider ID is set to pp_stripe_stripe, which is the ID of the Stripe payment provider in your Medusa application if the ID in medusa-config.ts is stripe. If you used a different ID, change the stripe in the middle accordingly.

After that, change the handlePayment function to the following:

src/modules/checkout/components/payment-button/index.tsx
1const handlePayment = async () => {2  if (!stripe || !elements || !cart) {3    return4  }5  setSubmitting(true)6
7  const { error: submitError } = await elements.submit()8  if (submitError) {9    setErrorMessage(submitError.message || null)10    setSubmitting(false)11    return12  }13
14  const clientSecret = paymentSession?.data?.client_secret as string15
16  await stripe17  .confirmPayment({18    elements,19    clientSecret,20    confirmParams: {21      return_url: `${22        window.location.origin23      }/api/capture-payment/${cart.id}?country_code=${countryCode}`,24      payment_method_data: {25        billing_details: {26          name:27            cart.billing_address?.first_name +28            " " +29            cart.billing_address?.last_name,30          address: {31            city: cart.billing_address?.city ?? undefined,32            country: cart.billing_address?.country_code ?? undefined,33            line1: cart.billing_address?.address_1 ?? undefined,34            line2: cart.billing_address?.address_2 ?? undefined,35            postal_code: cart.billing_address?.postal_code ?? undefined,36            state: cart.billing_address?.province ?? undefined,37          },38          email: cart.email,39          phone: cart.billing_address?.phone ?? undefined,40        },41      },42    },43    redirect: "if_required",44  })45  .then(({ error, paymentIntent }) => {46    if (error) {47      const pi = error.payment_intent48
49      if (50        (pi && pi.status === "requires_capture") ||51        (pi && pi.status === "succeeded")52      ) {53        onPaymentCompleted()54        return55      }56
57      setErrorMessage(error.message || null)58      setSubmitting(false)59      return60    }61
62    if (63      paymentIntent.status === "requires_capture" ||64      paymentIntent.status === "succeeded"65    ) {66      onPaymentCompleted()67    }68  })69}

In the function, you:

  • Ensure that the stripe, elements, and cart are available before proceeding.
  • Set the submitting state to true to indicate that the payment is being processed.
  • Use elements.submit() to submit the payment method details that the customer enters in the Payment Element. This ensures all necessary payment details are entered.
  • Use stripe.confirmPayment() to confirm the payment with Stripe. You pass it the following details:
    • elements: The Stripe Elements instance that contains the Payment Element.
    • clientSecret: The client secret from the payment session, which is used to confirm the payment.
    • confirmParams: An object that contains the parameters for confirming the payment.
      • return_url: The URL to redirect the customer to after the payment is confirmed. This redirect URL is useful when using providers like PayPal, where the customer is redirected to complete the payment externally. You'll create the route in your Next.js Starter Storefront in the next step.
      • payment_method_data: An object that contains the billing details of the customer.
    • redirect: The redirect behavior for the payment confirmation. By setting redirect: "if_required", you'll only redirect to the return_url if the payment is completed externally.
  • Handle the response from confirmPayment().
    • If the payment is successful or requires capture, you call the onPaymentCompleted function to complete the payment and place the order.
    • If an error occurred, you set the errorMessage state variable to show the error to the customer.

Finally, add the following useEffect hooks to the StripePaymentButton component:

src/modules/checkout/components/payment-button/index.tsx
1useEffect(() => {2  if (cart.payment_collection?.status === "authorized") {3    onPaymentCompleted()4  }5}, [cart.payment_collection?.status])6
7useEffect(() => {8  elements?.getElement("payment")?.on("change", (e) => {9    if (!e.complete) {10      // redirect to payment step if not complete11      router.push(pathname + "?step=payment", {12        scroll: false,13      })14    }15  })16}, [elements])

You add two effects:

  • An effect that runs when the status of the cart's payment collection changes. It triggers the onPaymentCompleted function to finalize the order placement when the payment collection status is authorized.
  • An effect that listens for changes in the Payment Element. If the payment element is not complete, it redirects the customer back to the payment step. This is useful if the customer refreshes the page or navigates away, leading to incomplete payment details.

You've finalized changes to the PaymentButton component. Feel free to remove unused imports, variables, and functions in the file.

You still need to add the redirect URL route before you can test the payment button. You'll do that in the next step.

Full updated code for src/modules/checkout/components/payment-button/index.tsx

Step 4: Handle External Payment Callbacks#

Some payment providers, such as PayPal, require the customers to perform actions on their portal before authorizing or confirming the payment. In those scenarios, you need an endpoint that the provider redirects the customer to complete their purchase.

In this step, you'll create an API route in your Next.js Starter Storefront that handles this use case. This route is the return_url route you passed to the stripe.confirmPayment earlier.

Create the file src/app/api/capture-payment/[cartId]/route.ts with the following content:

src/app/api/capture-payment/[cartId]/route.ts
1import { placeOrder, retrieveCart } from "@lib/data/cart"2import { NextRequest, NextResponse } from "next/server"3
4type Params = Promise<{ cartId: string }>5
6export async function GET(req: NextRequest, { params }: { params: Params }) {7  const { cartId } = await params8  const { origin, searchParams } = req.nextUrl9
10  const paymentIntent = searchParams.get("payment_intent")11  const paymentIntentClientSecret = searchParams.get(12    "payment_intent_client_secret"13  )14  const redirectStatus = searchParams.get("redirect_status") || ""15  const countryCode = searchParams.get("country_code")16
17  const cart = await retrieveCart(cartId)18
19  if (!cart) {20    return NextResponse.redirect(`${origin}/${countryCode}`)21  }22
23  const paymentSession = cart.payment_collection?.payment_sessions?.find(24    (payment) => payment.data.id === paymentIntent25  )26
27  if (28    !paymentSession ||29    paymentSession.data.client_secret !== paymentIntentClientSecret ||30    !["pending", "succeeded"].includes(redirectStatus) ||31    !["pending", "authorized"].includes(paymentSession.status)32  ) {33    return NextResponse.redirect(34      `${origin}/${countryCode}/cart?step=review&error=payment_failed`35    )36  }37
38  const order = await placeOrder(cartId)39
40  return NextResponse.redirect(41    `${origin}/${countryCode}/order/${order.id}/confirmed`42  )43}

In this route, you validate that the payment intent and client secret match the cart's payment session data. If so, you place the order and redirect the customer to the order confirmation page.

If the payment failed or other validation checks fail, you redirect the customer back to the cart page with an error message.

Stripe will redirect the customer back to this route when using payment methods like PayPal.


Test Payment Confirmation and Order placement#

You can now test out the entire checkout flow with payment confirmation and order placement.

Start the Medusa application and the Next.js Starter Storefront as you did in the previous steps, and proceed to checkout.

In the payment step, select a payment method, such as card or PayPal. Fill out the necessary details in the Payment Element, then click the "Continue to review" button.

Tip: Find test cards in Stripe's documentation.

After that, click the "Place order" button. If you selected a payment method that requires external confirmation, such as PayPal, you will be redirected to the PayPal payment page.

Example of handling payment through PayPal

Once the payment is confirmed and the order is placed, the customer will be redirected to the order confirmation page, where they can see the order details.

Test 3D Secure Payments#

Stripe's Payment Element also supports 3D Secure payments. You can use one of Stripe's test 3D Secure cards to test this feature.

When a customer uses a 3D Secure card, a pop-up will open prompting them to complete the authentication process. If successful, the order will be placed, and the customer will be redirected to the order confirmation page.

Stripe 3D Secure pop-up

Server Webhook Verification#

Webhook verification is useful to ensure that payment events are handled despite connection issues. The Stripe Module Provider in Medusa provides webhook verification out of the box, so you don't need to implement it yourself.

Learn more about the webhook API routes and how to configure them in the Stripe Module Provider guide.

Testing Declined Payments#

You can use Stripe's declined test cards to test declined payments. When a payment is declined, the customer will be redirected back to the payment step to fix their payment details as necessary.

Was this page helpful?
Ask Anything
FAQ
What is Medusa?
How can I create a module?
How can I create a data model?
How do I create a workflow?
How can I extend a data model in the Product Module?
Recipes
How do I build a marketplace with Medusa?
How do I build digital products with Medusa?
How do I build subscription-based purchases with Medusa?
What other recipes are available in the Medusa documentation?
Chat is cleared on refresh
Line break