Customize the Stripe Integration in the Next.js Starter

Stripe is a popular payment provider amongst Medusa users. While the Next.js Starter comes with basic Stripe card payments, you can customize it to accept different payment methods through the Stripe Payment Element. The Payment Element is a web UI component that supports over 40 payment methods (like PayPal, local banks, etc) while handling input validation and error management.

In this guide, you'll learn how to customize the starter's Stripe integration to accept multiple payment methods, such as PayPal, through Stripe.

Step 1: Setup Medusa Project#

In this step, you'll setup a Medusa application and configure the Stripe Module Provider.

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 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 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 credential and submit the form.

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

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

Configure Stripe Module Provider#

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-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              capture: true,14            },15          },16        ],17      },18    },19  ],20})

For more details about the available options and Stripe Webhook Endpoint URLs, refer to the Stripe Module Provider docs.

Environment Variables#

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

Terminal
STRIPE_API_KEY=<YOUR_STRIPE_API_KEY>

You also need to add the Stripe publishable API key in the Next.js Starter's environment variables:

Terminal
NEXT_PUBLIC_STRIPE_KEY=<YOUR_STRIPE_PUBLISHABLE_API_KEY>

Enable Stripe in a Region#

Lastly, you need to add Stripe as a payment provider to one or more regions in your Medusa Admin. To do so:

  1. Log in to you Medusa Admin dashboard at http://localhost:9000/app and go to Settings -> Regions.
  2. Create or select a region.
  3. Click the three dots on the upper right corner, then click “Edit”.
  4. For the Payment Providers field, select “Stripe (Stripe)”
  5. Click the Save button.

Step 2: Update the Payment Element#

TipTo use Stripe Payment components in a checkout form, you needs to wrap it in a Stripe Elements context provider. Since the Next.js Starter already comes with a simple Stripe Card Element integration, the necessary context is already provided. You don’t have to edit this part for the customizations made in this guide, but in case you wish to make any changes to the provider, the logic is handled in the src/modules/checkout/components/payment-wrapper/index.tsx and src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx files.

Within the <Payment> (src/modules/checkout/components/payment/index.tsx) component, you present the different payment options to the user. In this file, you'll change the payment integration approach by replacing the custom payment method selection with Stripe's Payment Element component.

NoteThe full final code is available at the end of the section.

First, import the PaymentElement component, two Stripe hooks and the type for change events within PaymentElement:

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

Next, alter the state management within the component to:

  1. Remove the cardBrand state.
  2. Rename cardComplete to stripeComplete.
  3. Keep the loading and error states.
  4. Update the type for selectedPaymentMethod to string and remove the default value.
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>()

As a next step you can remove the isStripe check on line 42. The Medusa payment provider will always be Stripe in this setup, managing different payment methods within the Payment Element. Also remove the useOptions object on line 51, as it’s no longer needed.

Next, initiate the two Stripe hooks you imported earlier, but only if the Stripe context is ready:

src/modules/checkout/components/payment/index.tsx
1const stripe = stripeReady ? useStripe() : null2const elements = stripeReady ? useElements() : null

To track changes in the PaymentElemement, add within the component a handler that handles different change events:

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}

Then, edit the handleSubmit function to handle user submissions of the payment step. This will not trigger the payment yet, but submit the payment details and render the final Review step in the checkout:

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  }

To make sure a payment session is initiated on the current Medusa cart once a user reaches the payment step, add within the component an initStripe function and call it from a useEffect hook:

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

Now that you have all the necessary setup in place, you'll add the Stripe PaymentElement to the Payment component. Replace the code inside the !paidByGiftcard && availablePaymentMethods?.length condition 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}

And refactor the button to reflect the new changes:

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>

Then, add a check for selectedPaymentMethod to the condition for the collapsed state (cart && paymentReady && activeSession):

{cart && paymentReady && activeSession && selectedPaymentMethod ? (
  // rest of code...
)}

Finally, remove the card brand check on line 229 (isStripeFunc(selectedPaymentMethod) && cardBrand) as you no longer support that. Just render the following text to indicate that the user will be redirected to an external payment environment:

Code
<Text>Another step will appear</Text>
Full updated code for src/modules/checkout/components/payment/index.tsx

Test it out#

To test out the changes you've made so far:

  1. Start both your Medusa and Next.js dev servers by running yarn dev in both projects.
  2. Navigate to http://localhost:8000 in your browser.
  3. Click a product and add it to cart.
  4. Navigate to http://localhost:8000/dk/checkout?step=address to start the checkout process.
  5. Fill out the Address and Delivery steps.
  6. Once you reach the Payment step, your Stripe Payment Element should show up and list the different payment methods you’ve enabled in your Stripe account. It looks something like this:

The stripe payment element allows you to pay with different payment methods like PayPal


Step 3: Update Payment Method Titles and Icons#

To show a user-friendly payment method title and icon when the method is selected, update the paymentInfoMap object in src/lib/constants.tsx:

src/lib/constants.tsx
1/* Map of payment provider_id to their title and icon. Add in any payment providers you want to use. */2export const paymentInfoMap: Record<3  string,4  { title: string; icon: React.JSX.Element }5> = {6  card: {7    title: "Credit card",8    icon: <CreditCard />,9  },10  ideal: {11    title: "iDeal",12    icon: <Ideal />,13  },14  bancontact: {15    title: "Bancontact",16    icon: <Bancontact />,17  },18  paypal: {19    title: "PayPal",20    icon: <PayPal />,21  },22  pp_system_default: {23    title: "Manual Payment",24    icon: <CreditCard />,25  },26  // Add more payment providers here27}

The paymentInfoMap object's keys must match the type enum in the Stripe PaymentMethod object. The value of each is an object having the following properties:

  • title: The title to show when the payment method is selected.
  • icon: A JSX element that is rendered for the icon.

Make sure to add an entry for every payment method you’re using.

For example, when PayPal is selected as the payment method in Stripe, the collapsed state will look like this:

The title and icon you configured are shown when PayPal is selected


Step 4: Update the Payment Button#

The <PaymentButton> component in src/modules/checkout/components/payment-button/index.tsx finalizes the checkout and initiates the payment for the user at the Review step.

The place order button is show at the bottom of the Review section

In this section, you'll simplify the payment button implementation by focusing solely on Stripe integration and removing other payment options. The new implementation will work exclusively with Stripe's updated payment flow. You'll replace the card-specific confirmation method with Stripe's unified payment confirmation system, add support for redirect-based payment methods, and implement automatic order completion upon successful authorization if no redirect is needed.

NoteThe full final code is available at the end of the section.

You'll first clean up the file and remove payment buttons that are no longer needed. You can completely remove PayPalPaymentButton, GiftCardPaymentButton and ManualTestPaymentButton. It’s also safe to remove the isManual and isPaypal cases from the switch statement.

The PaymentButton component should now look like this:

src/modules/checkout/components/payment-button/index.tsx
1const PaymentButton: React.FC<PaymentButtonProps> = ({2  cart,3  "data-testid": dataTestId,4}) => {5  const notReady =6    !cart ||7    !cart.shipping_address ||8    !cart.billing_address ||9    !cart.email ||10    (cart.shipping_methods?.length ?? 0) < 111
12  const paymentSession = cart.payment_collection?.payment_sessions?.[0]13
14  switch (true) {15    case isStripe(paymentSession?.provider_id):16      return (17        <StripePaymentButton18          notReady={notReady}19          cart={cart}20          data-testid={dataTestId}21        />22      )23    default:24      return <Button disabled>Select a payment method</Button>25  }26}

Since you're only using Stripe, you could decide to completely kill the switch statement and always return the Stripe button. However, if you might add more payment providers in the future, it could come in handy to have this logic in place.

Next, import at the top of the file useParams and add useEffect to the React imports:

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

In StripePaymentButton, grab the countryCode from the route params as you're going to need it later:

src/modules/checkout/components/payment-button/index.tsx
const { countryCode } = useParams()

Then, remove the card and session variables. and add the following line below countryCode to find the Stripe payment session:

src/modules/checkout/components/payment-button/index.tsx
1const paymentSession = cart.payment_collection?.payment_sessions?.find(2  (session) => session.provider_id === "pp_stripe_stripe"3)

After that, alter the handlePayment function to work with the Payment Element by removing the !card condition as the variable no longer exists:

src/modules/checkout/components/payment-button/index.tsx
1const handlePayment = async () => {2    if (!stripe || !elements || !cart) {3      return4    }5
6    setSubmitting(true)7    8    // ...rest of code9}

Next, grab the Stripe clientSecret from Stripe's payment session in Medusa:

src/modules/checkout/components/payment-button/index.tsx
const clientSecret = paymentSession?.data?.client_secret as string

To confirm the payment, you'll use stripe.confirmPayment instead of stripe.confirmCardPayment which expects different options:

src/modules/checkout/components/payment-button/index.tsx
1await stripe2  .confirmPayment({3    elements,4    clientSecret,5    confirmParams: {6      return_url: `${window.location.origin}/api/capture-payment/${cart.id}?country_code=${countryCode}`,7      payment_method_data: {8        billing_details: {9          name:10            cart.billing_address?.first_name +11            " " +12            cart.billing_address?.last_name,13          address: {14            city: cart.billing_address?.city ?? undefined,15            country: cart.billing_address?.country_code ?? undefined,16            line1: cart.billing_address?.address_1 ?? undefined,17            line2: cart.billing_address?.address_2 ?? undefined,18            postal_code: cart.billing_address?.postal_code ?? undefined,19            state: cart.billing_address?.province ?? undefined,20          },21          email: cart.email,22          phone: cart.billing_address?.phone ?? undefined,23        },24      },25    },26    redirect: "if_required",27  })28  .then(({ error }) => {29    if (error) {30      const pi = error.payment_intent31
32      if (33        (pi && pi.status === "requires_capture") ||34        (pi && pi.status === "succeeded")35      ) {36        onPaymentCompleted()37      }38
39      setErrorMessage(error.message || null)40      return41    }42
43    return44  })

You pass in elements the clientSecret you defined earlier, and a confirmParams object containing customer data and a return_url. This is the URL the user gets redirected to after completing an external payment. You'll create this route later.

By setting redirect: "if_required", you'll only redirect to this route if the payment is completed externally. If not, add the following useEffect hook to call the onPaymentCompleted function that completes the order:

src/modules/checkout/components/payment-button/index.tsx
1useEffect(() => {2  if (cart.payment_collection?.status === "authorized") {3    onPaymentCompleted()4  }5}, [cart.payment_collection?.status])

The returned <Button> component remains unchanged.

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

Step 5: Handle External Payment Callbacks#

Many payment methods redirect users away from your store to complete payment (for example, to their banking app) before returning the customer back to your store. You need to handle these redirects and check if the payment was successful. If so, you create a Medusa order and redirect the customer to the order confirmation page.

To handle this, you'll set up a Next.js API route. This is the route you passed as the return_url in stripe.confirmPayment in the previous step.

Create a route.ts file in src/app/api/capture-payment/[cartId] 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}

This route validates a payment intent from Stripe by checking the URL parameters (payment_intent, client_secret, and redirect_status) against the cart's payment session data. If the cart doesn't exist, or if payment validation fails (mismatched secrets, invalid status, etc...), the route redirects the customer back to the cart where you can display an error to the customer.

If the validation succeeds, you places the order using the placeOrder function and redirects the user to an order confirmation page.

Test it Out#

Switch back to the checkout page on your storefront and try to place an order with an external payment method (For example, PayPal). Stripe will redirect you to a payment page where you can either authorize or reject the payment.

Example of handling payment through PayPal

When you click “Authorize Test Payment”, you’ll be redirected to your newly created API route. If the validation passes, you should now see the order confirmation page.

Order confirmation page showing the order's details.

Was this page helpful?
Edit this page