- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
- Get Started
- Product
- Resources
- Tools & SDKs
- Framework
- Reference
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:
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.
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
.
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
:
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:
You also need to add the Stripe publishable API key in the Next.js Starter's environment variables:
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:
- Log in to you Medusa Admin dashboard at
http://localhost:9000/app
and go to Settings -> Regions. - Create or select a region.
- Click the three dots on the upper right corner, then click “Edit”.
- For the Payment Providers field, select “Stripe (Stripe)”
- Click the Save button.
Step 2: Update the Payment Element#
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.
First, import the PaymentElement
component, two Stripe hooks and the type for change events within PaymentElement
:
Next, alter the state management within the component to:
- Remove the
cardBrand
state. - Rename
cardComplete
tostripeComplete
. - Keep the loading and error states.
- Update the type for
selectedPaymentMethod
tostring
and remove the default value.
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:
To track changes in the PaymentElemement
, add within the component a handler that handles different change events:
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:
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:
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:
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:
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:
Full updated code for src/modules/checkout/components/payment/index.tsx
Test it out#
To test out the changes you've made so far:
- Start both your Medusa and Next.js dev servers by running
yarn dev
in both projects. - Navigate to
http://localhost:8000
in your browser. - Click a product and add it to cart.
- Navigate to
http://localhost:8000/dk/checkout?step=address
to start the checkout process. - Fill out the Address and Delivery steps.
- 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:
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
:
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:
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.
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.
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:
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:
In StripePaymentButton
, grab the countryCode
from the route params as you're going to need it later:
Then, remove the card
and session
variables. and add the following line below countryCode
to find the Stripe payment session:
After that, alter the handlePayment
function to work with the Payment Element by removing the !card
condition as the variable no longer exists:
Next, grab the Stripe clientSecret
from Stripe's payment session in Medusa:
To confirm the payment, you'll use stripe.confirmPayment
instead of stripe.confirmCardPayment
which expects different options:
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:
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:
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.
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.