Command Palette

Search for a command to run...

Integrate Stripe Payment into Storefront

To integrate the Stripe payment provider into the Medusa DTC Starter by Gorgo storefront, start by adding the required UI components, so the provider is displayed on the checkout page alongside other available payment methods.

The checkout uses a deferred PaymentIntent. The Stripe Elements are created with the cart's amount and currency and no PaymentIntent behind them, so picking Stripe or switching payment methods makes no request. The customer pays by card at the payment step or with Apple Pay, Google Pay or Link in the express checkout row. At that moment the storefront starts a payment session, the provider creates the PaymentIntent with the final amount and the delivery address from the cart, and Stripe.js confirms it.

Once the payment is confirmed, the storefront completes the cart and creates a new order in Medusa. If the customer closes the tab after paying, the or webhook creates the order instead.

For the storefront on Next.js, you need to make the following changes:

Step 1: Install Stripe.js

The payment form and the wallet buttons come from Stripe.js and its official React wrapper. Install both in the storefront:

yarn add @stripe/stripe-js@10.1.0 @stripe/react-stripe-js@7.0.0

Version 10 of loads the release of Stripe.js, which matches the API version the plugin uses on the backend, and provides the , and components.


Step 2: Configure Payment Provider

The payment provider mapping in your storefront's constants file decides how each payment provider appears on the checkout page. Stripe already has an entry there.

Open and change the entry:

Directory structure in the Medusa Storefront after updating the file for constants

src/lib/constants.tsx
1export const paymentInfoMap: Record<string, PaymentInfo> = {
2 pp_stripe_stripe: {
3 title: "Stripe",
4 subtitle: "Cards, wallets",
5 icon: <CreditCard />,
6 },
7 // ...
8}
9
10// ...
11
12export const isStripeLike = (providerId?: string) => {
13 return (
14 providerId?.startsWith("pp_stripe_") || providerId?.startsWith("pp_medusa-")
15 )
16}

The entry defines the title, the subtitle, and the icon shown for Stripe on the checkout page, and also serves as the fallback when a locale carries no translation. The starter already has the entry and the helper, because it ships a Stripe payment form, so you only change the title and the subtitle.

The helper function checks whether a given belongs to Stripe. The page, the payment button and the express checkout row all use it.


Step 3: Start the Payment Session from the Pay Button

The starter starts a payment session the moment the customer picks a method, which would create a PaymentIntent before the amount is final. Stripe's session starts from its pay button instead.

Open the same and add an entry to the session data builders:

Directory structure in the Medusa Storefront after updating the file for constants

src/lib/constants.tsx
1const paymentSessionDataBuilders: Array<{
2 test: (providerId?: string) => boolean | undefined
3 isReady?: (cart: HttpTypes.StoreCart) => boolean
4 build?: (cart: HttpTypes.StoreCart) => Record<string, unknown>
5 /**
6 * The provider's own pay button starts the payment session when the customer pays, so the
7 * checkout does not start one when the method is picked or the cart changes. For a provider
8 * that creates the payment at the vendor only once the amount is final, such as a deferred
9 * Stripe PaymentIntent. `isReady` still gates the pay button.
10 */
11 deferred?: boolean
12}> = [
13 {
14 // The Payment Element works on a deferred PaymentIntent, which the pay button creates
15 // once the amount is final, so picking Stripe makes no request.
16 test: isStripeLike,
17 deferred: true,
18 },
19]

With , the checkout starts no session when the customer picks Stripe, after the session is cleared, or when the address or the shipping changes. The entry has no , because the provider reads the address and the email from the cart on the backend. It has no either, because the pay button already waits for the email, the addresses and the shipping method.


Step 4: Load Stripe Settings

The storefront needs the publishable key and the capture method to create the Elements. They come from the plugin's settings in Medusa Admin, so the storefront loads them from the backend instead of an environment variable.

Create and add the following code:

Directory structure in the Medusa Storefront after creating the file for Stripe settings

src/lib/data/stripe.ts
1"use server"
2
3import { sdk } from "@lib/config"
4
5/** What the storefront needs to load Stripe.js and create the Elements, from `GET /store/stripe/config`. */
6export type StripeConfig = {
7 provider_id: string
8 publishable_key: string
9 /** The capture method of the PaymentIntents the backend creates. The Elements must use the same. */
10 capture_method: "manual" | "automatic_async"
11 payment_method_configuration: string | null
12}
13
14/**
15 * The public Stripe settings of the only Stripe integration, or null while it is not configured.
16 * The keys and the capture method change only in the Admin, so the answer is cached for a minute.
17 */
18export async function getStripeConfig(): Promise<StripeConfig | null> {
19 return sdk.client
20 .fetch<{ config: StripeConfig }>("/store/stripe/config", {
21 method: "GET",
22 next: { revalidate: 60 },
23 })
24 .then(({ config }) => config)
25 .catch(() => null)
26}

calls , which returns the publishable key, the capture method and the payment method configuration of the Stripe integration. It returns while the integration is not configured, and the answer is cached for a minute.


Step 5: Convert the Amount for Stripe

The Elements take the amount as an integer in the currency's smallest unit, and it has to equal the amount of the PaymentIntent the provider creates.

Create and add the following code:

Directory structure in the Medusa Storefront after creating the file for the amount conversion

src/lib/util/stripe-amount.ts
1/**
2 * Converts a Medusa amount, in major units, to the integer Stripe expects, the same way
3 * `@gorgo/medusa-payment-stripe` converts it for the PaymentIntent (`src/utils/amounts.ts` there).
4 * The Elements and the PaymentIntent have to agree on the amount, so a change here goes there too.
5 */
6const ZERO_DECIMAL = new Set([
7 "BIF", "CLP", "DJF", "GNF", "JPY", "KMF", "KRW", "MGA", "PYG", "RWF", "VND", "VUV", "XAF", "XOF", "XPF",
8])
9// zero-decimal currencies Stripe still takes multiplied by 100, so 5 ISK is 500
10const WHOLE_TWO_DECIMAL = new Set(["ISK", "UGX"])
11// thousandths, rounded to a hundredth, since Stripe wants the last digit to be 0
12const THREE_DECIMAL = new Set(["BHD", "JOD", "KWD", "OMR", "TND"])
13
14/** `amount` rounded half up to `decimals` places, then shifted to an integer, without float drift. */
15const shift = (amount: number, decimals: number) =>
16 Math.round(Number(`${amount.toFixed(10)}e${decimals}`))
17
18export const toStripeAmount = (amount: number, currencyCode: string) => {
19 const code = currencyCode.toUpperCase()
20 if (ZERO_DECIMAL.has(code)) {
21 return shift(amount, 0)
22 }
23 if (WHOLE_TWO_DECIMAL.has(code)) {
24 return shift(amount, 0) * 100
25 }
26 if (THREE_DECIMAL.has(code)) {
27 return shift(amount, 2) * 10
28 }
29 return shift(amount, 2)
30}

converts the cart's total the way the provider converts it on the backend. Zero-decimal currencies such as JPY stay whole, ISK and UGX are multiplied by 100, so 5 ISK is sent as 500, and the currencies with three decimal places are rounded to a hundredth, since Stripe wants their last digit to be 0.


Step 6: Wrap the Checkout in Elements

The card form and the wallet buttons share one set of Elements, which wraps the whole checkout.

Open and replace its code with the following:

Directory structure in the Medusa Storefront after updating the file for the Stripe wrapper

src/modules/checkout/components/payment-wrapper/stripe-wrapper.tsx
1"use client"
2
3import type { Stripe, StripeElementsOptions } from "@stripe/stripe-js"
4import { loadStripe } from "@stripe/stripe-js/pure"
5import { Elements } from "@stripe/react-stripe-js"
6import type { HttpTypes } from "@medusajs/types"
7import type { StripeConfig } from "@lib/data/stripe"
8import { toStripeAmount } from "@lib/util/stripe-amount"
9import { createContext, useMemo } from "react"
10
11type StripeWrapperProps = {
12 cart: HttpTypes.StoreCart
13 config: StripeConfig
14 children: React.ReactNode
15}
16
17export const StripeContext = createContext(false)
18
19// One Stripe.js instance per publishable key for the whole visit.
20const stripePromises = new Map<string, Promise<Stripe | null>>()
21
22const stripeFor = (publishableKey: string) => {
23 let promise = stripePromises.get(publishableKey)
24 if (!promise) {
25 promise = loadStripe(publishableKey)
26 stripePromises.set(publishableKey, promise)
27 }
28 return promise
29}
30
31/**
32 * Elements in deferred mode, created with the amount, the currency and the capture method of
33 * the PaymentIntent the backend will create, and without a client secret. Selecting Stripe or
34 * switching methods makes no request. The amount follows the cart, and a wallet updates it
35 * itself while its window is open.
36 */
37const StripeWrapper: React.FC<StripeWrapperProps> = ({
38 cart,
39 config,
40 children,
41}) => {
42 const stripePromise = useMemo(
43 () => stripeFor(config.publishable_key),
44 [config.publishable_key]
45 )
46
47 const options: StripeElementsOptions = {
48 mode: "payment",
49 amount: toStripeAmount(cart.total ?? 0, cart.currency_code),
50 currency: cart.currency_code.toLowerCase(),
51 captureMethod: config.capture_method,
52 ...(config.payment_method_configuration
53 ? { paymentMethodConfiguration: config.payment_method_configuration }
54 : {}),
55 }
56
57 return (
58 <StripeContext.Provider value={true}>
59 <Elements options={options} stripe={stripePromise}>
60 {children}
61 </Elements>
62 </StripeContext.Provider>
63 )
64}
65
66export default StripeWrapper

creates the Elements in mode with the cart's amount, its currency and the capture method from the settings, and without a client secret. When the cart's total changes, the new amount reaches the Elements through the prop. tells the components inside that they can use the Elements.

Open and replace its code with the following:

Directory structure in the Medusa Storefront after updating the file for the payment wrapper

src/modules/checkout/components/payment-wrapper/index.tsx
1"use client"
2
3import type React from "react"
4import StripeWrapper from "./stripe-wrapper"
5import type { HttpTypes } from "@medusajs/types"
6import type { StripeConfig } from "@lib/data/stripe"
7
8type PaymentWrapperProps = {
9 cart: HttpTypes.StoreCart
10 /** The Stripe settings, when Stripe is enabled in the cart's region. */
11 stripeConfig: StripeConfig | null
12 children: React.ReactNode
13}
14
15/**
16 * Wraps the whole checkout in the Stripe Elements when the cart's region takes Stripe, so the
17 * card form and the express checkout row share one deferred PaymentIntent setup. The
18 * PaymentIntent is created only when the customer pays.
19 */
20const PaymentWrapper: React.FC<PaymentWrapperProps> = ({
21 cart,
22 stripeConfig,
23 children,
24}) => {
25 if (stripeConfig && (cart.total ?? 0) > 0) {
26 return (
27 <StripeWrapper cart={cart} config={stripeConfig}>
28 {children}
29 </StripeWrapper>
30 )
31 }
32
33 return <div>{children}</div>
34}
35
36export default PaymentWrapper

now takes the Stripe settings as a prop and wraps the checkout in the Elements only when it has them and the cart has something to pay.

Open and pass the settings to the wrapper:

Directory structure in the Medusa Storefront after updating the file for the checkout page

src/app/[locale]/(checkout)/checkout/page.tsx
1import { listCartPaymentMethods } from "@lib/data/payment"
2import { getStripeConfig } from "@lib/data/stripe"
3import { isStripeLike } from "@lib/constants"
4// ...
5
6export default async function Checkout() {
7 // ...
8 const [customer, paymentMethods, stripeConfig] = await Promise.all([
9 retrieveCustomer(),
10 listCartPaymentMethods(cart.region?.id ?? ""),
11 getStripeConfig(),
12 ])
13 // the Elements wrap the checkout only where the cart's region takes Stripe
14 const regionTakesStripe = (paymentMethods ?? []).some((method) =>
15 isStripeLike(method.id)
16 )
17
18 return (
19 <PaymentWrapper
20 cart={cart}
21 stripeConfig={regionTakesStripe ? stripeConfig : null}
22 >
23 {/* ... */}
24 </PaymentWrapper>
25 )
26}

The page loads the settings on the server, together with the payment providers of the cart's region, and passes them only when Stripe is enabled in that region. Loading them in the page means the Elements are there from the first render, so the checkout form does not mount twice.


Step 7: Configure the Payment Element

The payment step shows the Payment Element, which lists the payment methods of your Stripe account.

Open and pass the option to the :

Directory structure in the Medusa Storefront after updating the file for the payment container

src/modules/checkout/components/payment-container/index.tsx
1<PaymentElement
2 // the checkout already has the billing address and passes it on
3 // when the payment is confirmed
4 options={{
5 layout: "accordion",
6 fields: { billingDetails: { address: "never" } },
7 }}
8 // ...
9 />

The checkout already asks for the billing address, so the Payment Element does not ask for it again. The pay button passes the address when it confirms the payment.


Step 8: Create Payment Button

The pay button is where the PaymentIntent comes to exist.

Open and replace its code with the following:

Directory structure in the Medusa Storefront after updating the file for the Stripe payment button

src/modules/checkout/components/payment-button/providers/stripe.tsx
42 } as PaymentMethodCreateParams.BillingDetails
43}
44
45/**
46 * Pays with the Payment Element. The PaymentIntent does not exist until here. The button
47 * validates the form, starts the payment session, which creates the PaymentIntent with the
48 * cart's final amount and delivery address, and confirms it with the session's client secret.
49 */
50const StripeConfirmButton = ({
51 cart,
52 providerId,
53 notReady,
54 label,
55 cartUpdating,
56 "data-testid": dataTestId,
57}: PaymentProviderButtonProps & { providerId: string }) => {
58 const locale = useLocale()
59 const stripe = useStripe()
60 const elements = useElements()
61 const {
62 submitting,
63 setSubmitting,
64 errorMessage,
65 setErrorMessage,
66 completeOrder,
67 } = usePlaceOrder()
68
69 const fail = (message?: string | null) => {
70 setErrorMessage(message || null)
71 setSubmitting(false)
72 }
73
74 const handlePayment = async () => {
75 if (!stripe || !elements || !cart || submitting) {
76 return
77 }
78
79 setSubmitting(true)
80 setErrorMessage(null)
81
82 const { error: formError } = await elements.submit()
83 if (formError) {
84 return fail(formError.message)
85 }
86
87 let clientSecret: string | undefined
88 try {
89 const { payment_collection } = await initiatePaymentSession(cart, {
90 provider_id: providerId,
91 })
92 const session = payment_collection?.payment_sessions?.find(
93 (s) => s.provider_id === providerId && s.status === "pending"
94 )
95 clientSecret = session?.data?.client_secret as string | undefined
96 } catch (err) {
97 return fail(err instanceof Error ? err.message : String(err))
98 }
99 if (!clientSecret) {
100 return fail("The payment could not be started.")
101 }
102
103 const { error, paymentIntent } = await stripe.confirmPayment({
104 elements,
105 clientSecret,
106 confirmParams: {
107 return_url: `${window.location.origin}/api/payment-return?cart_id=${cart.id}&locale=${locale}`,
108 payment_method_data: {
109 billing_details: toBillingDetails(cart),
110 },
111 },
112 // Only leave the site when the selected method actually requires it, so
113 // card payments still complete inline.
114 redirect: "if_required",
115 })
116
117 if (error) {
118 if (isPaymentSettled(error.payment_intent?.status)) {
119 return completeOrder()
120 }
121 return fail(error.message)
122 }
123 if (isPaymentSettled(paymentIntent.status)) {
124 return completeOrder()
125 }
126 setSubmitting(false)
127 }
128
129 return (
130 <PaymentButtonShell
131 label={label}
132 onClick={handlePayment}
133 disabled={!stripe || !elements || notReady}
134 loading={submitting || cartUpdating}
135 errorMessage={errorMessage}
136 errorTestId="stripe-payment-error-message"
137 data-testid={dataTestId}
138 />
139 )
140}
141
142/**
143 * The checkout leaves out the Stripe Elements when Stripe is not configured in the Admin or
144 * the cart has nothing to pay. The button then stays disabled instead of calling `useStripe()`
145 * outside the Elements provider.
146 */
147const StripePaymentButton = (
148 props: PaymentProviderButtonProps & { providerId: string }
149) => {
150 const stripeReady = useContext(StripeContext)
151
152 if (!stripeReady) {
153 return (
154 <PaymentButtonShell
155 label={props.label}
156 onClick={() => {}}
157 disabled
158 loading={props.cartUpdating}
159 errorMessage={null}
160 errorTestId="stripe-payment-error-message"
161 data-testid={props["data-testid"]}
162 />
163 )
164 }
165
166 return <StripeConfirmButton {...props} />
167}
168
169export default StripePaymentButton

When the customer clicks the button, it validates the Payment Element with , starts the payment session, and reads the PaymentIntent's client secret from the session. then confirms the PaymentIntent with the form's details and the billing address from the checkout. A card payment completes on the page, and a method that sends the customer to another site, such as iDEAL, brings them back through . Once the PaymentIntent is or , the button places the order. If the checkout has no Stripe Elements, because Stripe is not configured in the Admin or the cart total is zero, the button stays disabled.


Step 9: Register Payment Button

The payment button dispatcher needs the provider id to start Stripe's session.

Open and pass to the Stripe button:

Directory structure in the Medusa Storefront after updating the file for the payment button

src/modules/checkout/components/payment-button/index.tsx
1// ...
2 switch (true) {
3 case isStripeLike(activePaymentMethod):
4 return (
5 <StripePaymentButton {...providerProps} providerId={activePaymentMethod} />
6 )
7 // ...
8 }

The dispatcher already picks the Stripe button for providers, and the button now also receives the id of the provider it pays with.


Step 10: Add Stripe to the Express Checkout Row

The express checkout row shows Apple Pay, Google Pay and Link above the checkout form, through Stripe's Express Checkout Element. The row and its registry come with the starter and are described in Set Up Express Checkout.

Create and add the following code:

Directory structure in the Medusa Storefront after creating the file for the Stripe express button

src/modules/checkout/components/express-checkout/buttons/stripe.tsx
46}
47
48function StripeWalletButton({
49 cart,
50 providerId,
51 shippingOptionId,
52 onUnavailable,
53 onError,
54 onBusy,
55 placeOrder,
56 onClosed,
57}: ExpressButtonProps) {
58 const t = useTranslations("CheckoutPage")
59 const locale = useLocale()
60 const stripe = useStripe()
61 const elements = useElements()
62 const currency = cart.currency_code
63 // what the wallet sheet shows, the quoted shipping and the total the customer approves
64 const rateRef = useRef<ShippingRate | null>(null)
65 const shownTotalRef = useRef<number | undefined>(undefined)
66
67 const currentShipping = cart.shipping_methods?.at(-1)
68 const initialRate: ShippingRate = {
69 id: currentShipping?.shipping_option_id ?? "shipping",
70 displayName: currentShipping?.name ?? t("shippingCost"),
71 amount: toStripeAmount(currentShipping?.amount ?? 0, currency),
72 }
73
74 const fail = (
75 event: StripeExpressCheckoutElementConfirmEvent,
76 error: Parameters<ExpressButtonProps["onError"]>[0]
77 ) => {
78 event.paymentFailed({ reason: "fail" })
79 onError(error)
80 onBusy(false)
81 onClosed()
82 }
83
84 const handleShippingAddressChange = async (
85 event: StripeExpressCheckoutElementShippingAddressChangeEvent
86 ) => {
87 try {
88 // the wallet shares only the city, region, postal code and country before payment
89 const quote = await quoteExpressShipping(
90 {
91 country_code: event.address.country,
92 province: event.address.state,
93 city: event.address.city,
94 postal_code: event.address.postal_code,
95 },
96 rateRef.current?.id ?? shippingOptionId
97 )
98 if (!quote.ok) {
99 return event.reject()
100 }
101 rateRef.current = {
102 id: quote.shipping_option_id,
103 displayName: quote.shipping_option_name,
104 amount: toStripeAmount(quote.shipping_total, quote.currency_code),
105 }
106 shownTotalRef.current = quote.total
107 elements?.update({ amount: toStripeAmount(quote.total, quote.currency_code) })
108 event.resolve({ shippingRates: [rateRef.current] })
109 } catch {
110 event.reject()
111 }
112 }
113
114 const handleConfirm = async (event: StripeExpressCheckoutElementConfirmEvent) => {
115 if (!stripe || !elements) {
116 return fail(event, { code: "failed" })
117 }
118 onBusy(true)
119
120 const { error: formError } = await elements.submit()
121 if (formError) {
122 return fail(event, { code: "failed", cause: formError })
123 }
124
125 try {
126 const address = event.shippingAddress?.address
127 // the full address arrives only now, after the customer approved the payment
128 const saved = await saveExpressContact(
129 {
130 email: event.billingDetails?.email,
131 phone: event.billingDetails?.phone,
132 shipping_address: address
133 ? {
134 ...splitName(event.shippingAddress?.name),
135 address_1: address.line1,
136 address_2: address.line2,
137 city: address.city,
138 province: address.state,
139 postal_code: address.postal_code,
140 country_code: address.country,
141 }
142 : null,
143 },
144 rateRef.current?.id ?? shippingOptionId,
145 shownTotalRef.current
146 )
147 if (!saved.ok) {
148 // no PaymentIntent exists yet, so nothing was charged
149 return fail(
150 event,
151 saved.reason === "totalChanged"
152 ? { code: "totalChanged", total: saved.total, currency_code: saved.currency_code }
153 : { code: saved.reason }
154 )
155 }
156
157 // the wallet sends its delivery address with the confirmation, so the provider sets none
158 const session = await startExpressPayment(providerId, { express: true })
159 const clientSecret = session.client_secret
160 if (typeof clientSecret !== "string") {
161 return fail(event, { code: "failed" })
162 }
163
164 const { error, paymentIntent } = await stripe.confirmPayment({
165 elements,
166 clientSecret,
167 confirmParams: {
168 return_url: `${window.location.origin}/api/payment-return?cart_id=${cart.id}&locale=${locale}`,
169 },
170 redirect: "if_required",
171 })
172 const status = error ? error.payment_intent?.status : paymentIntent.status
173 if (!isPaymentSettled(status)) {
174 onError({ code: "failed", cause: error })
175 onBusy(false)
176 onClosed()
177 return
178 }
179 await placeOrder()
180 } catch (err) {
181 if (isNavigation(err)) return
182 onError({ code: "failed", cause: err })
183 onBusy(false)
184 onClosed()
185 }
186 }
187
188 return (
189 // `isolate` keeps the wallet iframes under the checkout modals
190 <div className="isolate">
191 <ExpressCheckoutElement
192 options={{
193 emailRequired: true,
194 phoneNumberRequired: true,
195 shippingAddressRequired: true,
196 allowedShippingCountries: (cart.region?.countries ?? [])
197 .map((country) => country.iso_2?.toUpperCase())
198 .filter((code): code is string => Boolean(code)),
199 // PayPal has its own plugin, and Klarna and Amazon Pay need the pre-authorized
200 // amount to match the final one
201 paymentMethods: {
202 applePay: "auto",
203 googlePay: "auto",
204 link: "auto",
205 paypal: "never",
206 klarna: "never",
207 amazonPay: "never",
208 },
209 buttonHeight: 48,
210 layout: { maxColumns: 3, overflow: "never" },
211 }}
212 onReady={({ availablePaymentMethods }) => {
213 if (!availablePaymentMethods) onUnavailable()
214 }}
215 onLoadError={() => onUnavailable()}
216 onClick={(event) => {
217 // resolved within Stripe's one second, so nothing here waits on the server
218 onError(null)
219 rateRef.current = null
220 shownTotalRef.current = undefined
221 event.resolve({ shippingRates: [initialRate] })
222 }}
223 onShippingAddressChange={handleShippingAddressChange}
224 onShippingRateChange={(event) => event.resolve()}
225 onConfirm={handleConfirm}
226 onCancel={() => {
227 onBusy(false)
228 onClosed()
229 }}
230 />
231 </div>
232 )
233}
234
235/** Tells the row there is no Stripe button where the checkout is not inside the Stripe Elements. */
236function Unavailable({ onMount }: { onMount: () => void }) {
237 useEffect(onMount, [onMount])
238 return null
239}
240
241/**
242 * Apple Pay, Google Pay and Link in the express checkout row. The customer picks the address
243 * in the wallet, the row answers with the shipping and the new total, and the PaymentIntent is
244 * created and confirmed once they approve.
245 */
246export default function StripeExpressButton(props: ExpressButtonProps) {
247 const insideElements = useContext(StripeContext)
248 if (!insideElements) {
249 return <Unavailable onMount={props.onUnavailable} />
250 }
251 return <StripeWalletButton {...props} />
252}

The button answers the wallet's events with the functions from :

  • On , the button calls at once with the shipping the checkout has selected, because Stripe waits one second for the answer.
  • brings the city, the region, the postal code and the country. sets them on the cart with a shipping option, and the button updates the amount of the Elements and the shipping rate in the wallet. The button rejects a country the region does not ship to.
  • brings the full address, the email and the phone. saves them to the cart, and refuses when the shipping for the full address raises the total above what the customer approved, before anything is charged. then starts the session with without refreshing the page, and confirms the PaymentIntent. The wallet sends its delivery address with the confirmation, and Stripe refuses that for an address the backend has already set, so the provider sets none on an express PaymentIntent.
  • On , the row refreshes the checkout, because the cart may now hold the wallet's address.

The wallets appear only on a domain registered with Stripe, as described in Register Domains for Wallets. Where the cart's region has no Stripe, the checkout has no Elements, and the button tells the row it is unavailable.

Open and add the button to the registry:

Directory structure in the Medusa Storefront after updating the file for the express checkout registry

src/modules/checkout/components/express-checkout/registry.ts
1import StripeExpressButton from "./buttons/stripe"
2
3// ...
4
5export const expressButtons: {
6 match: (providerId: string) => boolean
7 Component: ComponentType<ExpressButtonProps>
8}[] = [
9 { match: (providerId) => Boolean(isStripeLike(providerId)), Component: StripeExpressButton },
10]

The row shows the button when the cart's region has a provider that accepts.


Step 11: Add Provider Labels to Storefront Locales

Change in and in every other locale under :

messages/en.json
1{
2 "PaymentMethods": {
3 // ...
4 "pp_stripe_stripe": {
5 "title": "Stripe",
6 "subtitle": "Cards, wallets"
7 }
8 }
9}

A locale missing an entry falls back to the English and from in Step 2.


Full Storefront Integration Code Example

The complete storefront is located at , built on the Medusa DTC Starter by Gorgo. The example's own README lists every file the integration consists of, backend included.

Edited Oct 27, 2026·Edit this page