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:
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.
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
.
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
:
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:
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:
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:
- Log in to your Medusa Admin dashboard at
http://localhost:9000/app
. - Go to Settings -> Regions.
- Select a region you want to enable Stripe for (or create a new one).
- Click the icon on the upper right corner.
- Choose "Edit" from the dropdown menu.
- In the Payment Providers field, select “Stripe (STRIPE)”
- 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.
First, add the following imports at the top of the file:
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:
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
oreps
.stripeReady
: A boolean that indicates whether Stripe is ready to be used, fetched from theStripeContext
.- This context is defined in
src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx
and it wraps the checkout page in Stripe'sElements
component, which initializes the Stripe SDKs.
- This context is defined in
stripe
: The Stripe instance, which is used to interact with the Stripe API. It's only initialized ifstripeReady
from the Stripe context is true.elements
: The Stripe Elements instance, which is used to manage the Payment Element. It's also only initialized ifstripeReady
is true.
Next, add the following function to the Payment
component to handle changes in the Payment Element:
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:
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:
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:
And replace them with the following:
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 thehandlePaymentElementChange
function you defined earlier.options
: An object that contains options for the payment element, such as thelayout
. Refer to Stripe's documentation for other options available.
Next, find the button rendered afterward and replace it with the following:
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:
And replace them with the following:
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:
And replace them with the following:
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.
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:
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:
- Start the Medusa application with the following command:
- Start the Next.js Starter Storefront with the following command:
- Open the storefront at
http://localhost:8000
in your browser. - Go to Menu -> Store, choose a product, and add it to the cart.
- Proceed to checkout by clicking the cart icon in the top right corner, then click "Go to checkout".
- Complete the Address and Delivery steps that are before the Payment step.
- 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.
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.
Start by adding the following imports at the top of the file:
Then, in the StripePaymentButton
component, add the following variables:
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 theclientSecret
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 inmedusa-config.ts
isstripe
. If you used a different ID, change thestripe
in the middle accordingly.
- Notice that the provider ID is set to
After that, change the handlePayment
function to the following:
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
, andcart
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 settingredirect: "if_required"
, you'll only redirect to thereturn_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.
- If the payment is successful or requires capture, you call the
Finally, add the following useEffect
hooks to the StripePaymentButton
component:
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 isauthorized
. - 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:
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.
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.
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.
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.