Command Palette

Search for a command to run...

Integrate Mollie Payment into Storefront

To integrate the Mollie 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.

Picking Mollie makes no request. When the customer clicks the payment button, it starts a payment session, the provider creates the payment in Mollie and returns the address of Mollie's payment page, and the button sends the customer there, where they pay with a card, iDEAL, PayPal or another method enabled in your Mollie profile.

Once the payment is completed, Mollie calls the webhook and sends the customer back to the storefront. Whichever event arrives first completes the cart and creates a new order in Medusa.

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

Step 1: Configure Payment Provider

To make Mollie available as a payment method on the storefront checkout page, add its configuration to the payment provider mapping in your storefront's constants file. This mapping determines how each payment provider is displayed in the UI.

Open and add the following code:

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

src/lib/constants.tsx
1export const paymentInfoMap: Record<string, PaymentInfo> = {
2 // ...
3 pp_mollie_mollie: {
4 title: "Mollie",
5 subtitle: "Cards, iDEAL, PayPal",
6 icon: <CreditCard />,
7 },
8 // Add more payment providers here
9}
10
11// ...
12
13export const isMollie = (providerId?: string) => {
14 return providerId?.startsWith("pp_mollie_")
15}

You extend the object with a entry. It defines the title, the subtitle and the icon shown for Mollie on the checkout page, and serves as the fallback when a locale carries no translation. Mollie takes many payment methods, so the entry uses the starter's generic icon.

The helper function checks whether a given belongs to Mollie. The payment button dispatcher and the order page use it in the steps below.


Step 2: Initialize Payment Session

To send a customer to Mollie, the payment session needs the address the customer returns to after paying and the language of Mollie's page.

Open the same and import the default locale and the storefront's base URL at the top of the file:

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

src/lib/constants.tsx
1import { defaultLocale } from "@i18n/config"
2import { getBaseURL } from "@lib/util/env"

Then add the page language, the return address and the session data builder below the payment provider mapping:

src/lib/constants.tsx
1/**
2 * The language of the page. `<html lang>` carries the locale the checkout was
3 * rendered in, which a provider with its own payment page takes to show that
4 * page in the customer's language.
5 */
6const pageLocale = () =>
7 typeof document === "undefined"
8 ? defaultLocale
9 : document.documentElement.lang || defaultLocale
10
11/**
12 * The address the customer comes back to after paying on the provider's own
13 * page, in the language they left the shop with.
14 */
15const paymentReturnUrl = (providerId: string, cartId: string) => {
16 const origin =
17 typeof window === "undefined" ? getBaseURL() : window.location.origin
18
19 return `${origin}/api/payment-return?provider=${providerId}&cart_id=${cartId}&locale=${pageLocale()}`
20}
21
22const paymentSessionDataBuilders: Array<{
23 test: (providerId?: string) => boolean | undefined
24 isReady?: (cart: HttpTypes.StoreCart) => boolean
25 build: (cart: HttpTypes.StoreCart) => Record<string, unknown>
26 /**
27 * The provider's own pay button starts the payment session when the customer
28 * pays, so the checkout does not start one when the method is picked or the
29 * cart changes. `isReady` still gates the pay button.
30 */
31 deferred?: boolean
32}> = [
33 {
34 test: (providerId) => isMollie(providerId),
35 // A Mollie payment is created with the session, and Mollie neither changes
36 // its amount nor cancels it while it is open, so a session replaced at the
37 // payment step would leave a payable page behind. The pay button starts the
38 // session, once the checkout has the email, the address and the shipping
39 // method the payment is created with.
40 deferred: true,
41 isReady: (cart) =>
42 Boolean(
43 cart.email &&
44 cart.shipping_address &&
45 (cart.shipping_methods?.length ?? 0) > 0
46 ),
47 build: (cart) => ({
48 redirect_url: paymentReturnUrl("mollie", cart.id),
49 locale: pageLocale(),
50 }),
51 },
52]

Below , add the helper that tells the checkout whether a method starts its session from the payment button:

src/lib/constants.tsx
1export const isPaymentSessionDeferred = (providerId: string | undefined) => {
2 return !!paymentSessionDataBuilders.find((b) => b.test(providerId))?.deferred
3}

When initiating a payment for Mollie, passes , the absolute address the customer comes back to, and , the language of the page. The provider creates the Mollie payment with them and stores the address of Mollie's payment page in the session as . It turns a bare language such as into Mollie's , and for a language Mollie does not have it leaves the choice to the customer's browser.

The points back to your storefront's route and is used for paid, canceled and failed payments alike. The route finds the cart by its .

With , picking Mollie or changing the cart starts no session, and keeps the payment button disabled until the cart carries an email, a shipping address and a shipping method. The provider creates the Mollie payment as soon as the session starts, with the cart's total and the buyer's addresses. Mollie neither changes the amount of that payment nor cancels it while it is open, so a session started when the method is picked and replaced later would leave a payable Mollie page behind.


Step 3: Start the Payment Session from the Pay Button

The starter's checkout starts a payment session when the customer picks a method, when the cart changes and when the session disappears. For Mollie, the payment button starts it instead.

Open and skip a deferred method in those three places:

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

src/modules/checkout/components/checkout-payment-section/index.tsx
1import {
2 // ...
3 isPaymentSessionDeferred,
4 isPaymentSessionReady,
5 // ...
6} from "@lib/constants"
7
8// ...
9
10 // Re-initiate payment session when it gets cleared (e.g. after promo code change)
11 useEffect(() => {
12 if (
13 selectedPaymentMethod &&
14 !latestSession &&
15 !paidByGiftcard &&
16 !isPaymentSessionDeferred(selectedPaymentMethod) &&
17 isPaymentSessionReady(selectedPaymentMethod, cart)
18 // ...
19
20// ...
21
22 if (
23 !selectedPaymentMethod ||
24 paidByGiftcard ||
25 isPaymentSessionDeferred(selectedPaymentMethod)
26 ) {
27 return
28 }
29
30// ...
31
32 const handleSelectPayment = async (providerId: string) => {
33 setSelectedPaymentMethod(providerId)
34
35 // a deferred provider's pay button starts the session when the customer pays
36 if (
37 isPaymentSessionDeferred(providerId) ||
38 !isPaymentSessionReady(providerId, cart)
39 ) {
40 return
41 }

The effect that restarts a cleared session, the effect that starts one again when the addresses, the email, the shipping or the region change, and the handler of the method picker now leave a deferred method alone. The checkout still shows Mollie as selected, and the payment button decides when the payment is created.


Step 4: Create Payment Button

The storefront needs a dedicated payment button component for each payment provider. Using the payment session data, it sends the customer to Mollie's payment page. The order itself is placed later, when the customer comes back through , or earlier through the webhook if that arrives first.

Create and add the following code:

Directory structure in the Medusa Storefront after creating the file for the Mollie payment button

src/modules/checkout/components/payment-button/providers/mollie.tsx
1"use client"
2
3import { buildPaymentSessionData } from "@lib/constants"
4import { initiatePaymentSession } from "@lib/data/cart"
5import type { HttpTypes } from "@medusajs/types"
6import { useTranslations } from "next-intl"
7import { useState } from "react"
8import { PaymentButtonShell, type PaymentProviderButtonProps } from "../shared"
9
10const MOLLIE_PROVIDER_ID = "pp_mollie_mollie"
11
12const findSession = (payment_collection?: HttpTypes.StorePaymentCollection | null) =>
13 payment_collection?.payment_sessions?.find((s) =>
14 s.provider_id.startsWith("pp_mollie_")
15 )
16
17/**
18 * The address of the payment page of a session started earlier, unless it is
19 * missing or Mollie has let the payment expire. A customer who went back from
20 * Mollie's page without paying returns to the same page this way, instead of a
21 * second payment Mollie would refuse to cancel. A card payment expires after
22 * 30 minutes.
23 */
24const liveCheckoutUrl = (session?: HttpTypes.StorePaymentSession) => {
25 const url = session?.data?.checkout_url
26 const expiresAt = session?.data?.expires_at
27
28 if (typeof url !== "string" || !url) {
29 return null
30 }
31
32 if (typeof expiresAt === "string" && Date.parse(expiresAt) <= Date.now()) {
33 return null
34 }
35
36 return url
37}
38
39/**
40 * Mollie collects the payment on its own page. This button starts the payment
41 * session, which creates the Mollie payment, and sends the customer to its
42 * page; the order is placed once they come back through `/api/payment-return`,
43 * or by the webhook if that arrives first. A session started before, whose
44 * payment is still open, is reused.
45 */
46const MolliePaymentButton = ({
47 cart,
48 notReady,
49 label,
50 cartUpdating,
51 "data-testid": dataTestId,
52}: PaymentProviderButtonProps) => {
53 const t = useTranslations("Errors")
54 const [submitting, setSubmitting] = useState(false)
55 const [errorMessage, setErrorMessage] = useState<string | null>(null)
56
57 const handlePayment = async () => {
58 setSubmitting(true)
59 setErrorMessage(null)
60
61 try {
62 const session = findSession(cart.payment_collection)
63 let url = liveCheckoutUrl(session)
64
65 if (!url) {
66 const providerId = session?.provider_id ?? MOLLIE_PROVIDER_ID
67 const { payment_collection } = await initiatePaymentSession(cart, {
68 provider_id: providerId,
69 data: buildPaymentSessionData(providerId, cart),
70 })
71
72 url = liveCheckoutUrl(findSession(payment_collection))
73 }
74
75 if (!url) {
76 throw new Error(t("paymentFailed"))
77 }
78
79 window.location.assign(url)
80 } catch (error) {
81 setErrorMessage(error instanceof Error ? error.message : t("paymentFailed"))
82 setSubmitting(false)
83 }
84 }
85
86 return (
87 <PaymentButtonShell
88 label={label}
89 onClick={handlePayment}
90 disabled={notReady || cartUpdating}
91 loading={submitting}
92 errorMessage={errorMessage}
93 errorTestId="mollie-payment-error-message"
94 data-testid={dataTestId ?? "submit-order-button"}
95 />
96 )
97}
98
99export default MolliePaymentButton

When the customer clicks the payment button, the component starts a payment session with the data from , reads from the new Mollie session and sends the customer to this address to pay on Mollie's page.

A customer who went back from Mollie's page without paying already has a session with an open payment. The button sends them to the same page again unless its has passed, since a card payment expires after 30 minutes, and only then starts a new session. A second open payment for the same cart is what the deferred session avoids, because Mollie refuses to cancel the first one. If no address comes back, the button shows an error message instead of navigating anywhere.


Step 5: Register Payment Button

Import the button and in , then add a for it in the dispatcher's :

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

src/modules/checkout/components/payment-button/index.tsx
1import {
2 isManual,
3 isMollie,
4 // ...
5} from "@lib/constants"
6// ...
7import MolliePaymentButton from "./providers/mollie"
8
9// ...
10
11 switch (true) {
12 // ...
13 case isMollie(activePaymentMethod):
14 return <MolliePaymentButton {...providerProps} />
15 // ...
16 }

The parent uses to determine whether to display for the current session; otherwise it falls through to the next case.


Step 6: Handle Payment Return

After the customer pays, cancels or fails on Mollie's page, Mollie sends them back to the storefront. Mollie reports the payment status through both this redirect and a webhook, so the shared route checks the cart for an order before placing one itself.

Open and add the following code:

Directory structure in the Medusa Storefront after updating the file for API route

src/app/api/payment-return/route.ts
1type ReturnContext = {
2 cartId: string
3 locale: string
4 origin: string
5}
6
7/**
8 * Comes back from a provider that takes the customer to its own payment page
9 * and returns them with nothing but the cart id, as Mollie and YooKassa do.
10 *
11 * The provider's webhook and this redirect race each other, and whichever
12 * arrives first places the order, so the cart is checked for an order before
13 * one is placed.
14 */
15async function redirectProviderReturn({
16 cartId,
17 locale,
18 origin,
19}: ReturnContext) {
20 const cart = await sdk.client
21 .fetch<HttpTypes.StoreCartResponse>(`/store/carts/${cartId}`, {
22 method: "GET",
23 query: { fields: "id,order_link.order_id" },
24 headers: { ...(await getAuthHeaders()) },
25 cache: "no-store",
26 })
27 .then(({ cart }) => cart)
28 .catch(() => null)
29
30 if (!cart) {
31 return NextResponse.redirect(
32 `${origin}/${locale}/cart?error=payment_failed`
33 )
34 }
35
36 const orderId = (cart as unknown as { order_link?: { order_id?: string } })
37 .order_link?.order_id
38
39 if (orderId) {
40 const orderCacheTag = await getCacheTag("orders")
41 revalidateTag(orderCacheTag)
42 await removeCartId()
43
44 return NextResponse.redirect(
45 `${origin}/${locale}/order/${orderId}/confirmed`
46 )
47 }
48
49 // The customer backed out or the payment was declined, so they land back on
50 // the checkout with their cart intact and can pick a method again.
51 await setCartId(cartId)
52
53 try {
54 await placeOrder(cartId)
55 } catch (error) {
56 unstable_rethrow(error)
57
58 return NextResponse.redirect(
59 `${origin}/${locale}/checkout?error=payment_failed`
60 )
61 }
62
63 return NextResponse.redirect(
64 `${origin}/${locale}/checkout?error=payment_failed`
65 )
66}

Then route Mollie's return to , in :

src/app/api/payment-return/route.ts
1if (provider === "mollie") {
2 return redirectProviderReturn({ cartId, locale, origin })
3 }

This route handles the return from Mollie after a payment attempt. It always fetches the cart's latest state, uncached, so it doesn't miss changes made during payment.

If the cart already has an order, the confirmation already arrived through the webhook. The route revalidates the order cache, removes the cart cookie, and sends the customer to the confirmation page.

Otherwise the route tries to place the order itself. If successful, the customer ends up on the order confirmation page. If the customer canceled on Mollie's page or the payment failed, placing the order fails, and the customer is sent back to the checkout with so they can pick a method again.


Step 7: Show the Card on the Order Page

For a card payment, the provider keeps the card's last four digits in the payment data as , the way Gorgo's Stripe and Authorize.net providers do. The order page of the starter shows them for Stripe only.

Open and change the condition:

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

src/modules/order/components/payment-details/index.tsx
1const { title, subtitle } = paymentMethodLabel(tm, payment.provider_id)
2 const card =
3 (isStripeLike(payment.provider_id) || isMollie(payment.provider_id)) &&
4 payment.data?.card_last4
5 // The card the order was actually paid with says more than the schemes the
6 // method accepts, so it takes the subtitle when the payment carries one.
7 const methodSubtitle = card ? `•••• ${card}` : subtitle

The order page now shows under Mollie for a card payment. For iDEAL, PayPal and the other methods, it keeps the subtitle from the locale.


Step 8: Add Provider Labels to Storefront Locales

Add to and to every other locale under :

messages/en.json
1{
2 "PaymentMethods": {
3 // ...
4 "pp_mollie_mollie": {
5 "title": "Mollie",
6 "subtitle": "Cards, iDEAL, PayPal"
7 }
8 }
9}

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


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 Nov 10, 2026·Edit this page