Mollie Components inside the 3D card, in the App Router
app/pay/checkout.tsx
"use client";
import Script from "next/script";
import { useEffect, useState } from "react";
import CardCheckout, { type CardFieldsSlot } from "@/components/CardCheckout";
import type { MollieComponent, MollieFieldType, MollieInstance } from "@/lib/mollie-js";
import { createCardPayment } from "./actions";
// Mollie needs all four fields mounted before it gives you a card token, card holder included.
const FIELDS: { type: MollieFieldType; label: string; focus: "number" | "expiry" | "cvc" | null }[] = [
{ type: "cardNumber", label: "Card number", focus: "number" },
{ type: "cardHolder", label: "Cardholder name", focus: null },
{ type: "expiryDate", label: "Expiry", focus: "expiry" },
{ type: "verificationCode", label: "CVC", focus: "cvc" },
];
let mollie: MollieInstance | null = null; // one Mollie object per page load
function getMollie() {
if (!mollie && window.Mollie) {
mollie = window.Mollie(process.env.NEXT_PUBLIC_MOLLIE_PROFILE_ID!, {
locale: "en_US",
testmode: process.env.NEXT_PUBLIC_MOLLIE_TESTMODE === "true",
});
}
return mollie;
}
function MollieFields({ slot, ready }: { slot: CardFieldsSlot; ready: boolean }) {
useEffect(() => {
const m = ready ? getMollie() : null;
if (!m) return;
const valid: Partial<Record<MollieFieldType, boolean>> = {};
const mounted: MollieComponent[] = FIELDS.map(({ type, focus }) => {
const field = m.createComponent(type, { styles: { base: { color: "#F4F4F6", fontSize: "17px" } } });
field.mount(`#mollie-${type}`);
field.addEventListener("change", (e) => {
valid[type] = !!e.dirty && !e.error;
slot.onChange({ complete: FIELDS.every((f) => valid[f.type]) });
});
field.addEventListener("focus", () => slot.onChange({ focus }));
field.addEventListener("blur", () => slot.onChange({ focus: null }));
return field;
});
return () => mounted.forEach((field) => field.unmount());
}, [ready, slot]);
return (
<>
{FIELDS.map((f) => (
<div key={f.type} className="mb-[15px]">
<span className={slot.labelClassName}>{f.label}</span>
<div id={`mollie-${f.type}`} className={slot.fieldClassName} />
</div>
))}
</>
);
}
// The card waits on this. Mollie has a token now, but nobody has paid: the bank's 3-D Secure step is next,
// on Mollie's page. So go there and leave the promise pending, and the button stays on Paying.
async function pay() {
const m = getMollie();
if (!m) throw new Error("The card form is still loading. Try again in a second.");
const { token, error } = await m.createToken();
if (error || !token) throw new Error(error?.message ?? "Check the card details and try again.");
const result = await createCardPayment(token);
if (!result.ok) throw new Error(result.message);
window.location.assign(result.checkoutUrl);
return new Promise<never>(() => {});
}
export default function Checkout() {
const [ready, setReady] = useState(false);
return (
<>
<Script src="https://js.mollie.com/v1/mollie.js" onReady={() => setReady(true)} />
<CardCheckout
amount={24} // the total your server charges, shown on the button
currency="EUR"
renderCardFields={(slot) => <MollieFields slot={slot} ready={ready} />}
onConfirm={pay}
resetAfter={null}
/>
</>
);
}
app/pay/actions.ts
"use server";
import { PaymentMethod } from "@mollie/api-client";
import { mollieClient } from "@/lib/mollie";
import { getOpenOrder, savePaymentId } from "@/lib/orders";
const SITE = process.env.NEXT_PUBLIC_SITE_URL!; // e.g. https://shop.example.com, no trailing slash
type Result = { ok: true; checkoutUrl: string } | { ok: false; message: string };
export async function createCardPayment(cardToken: string): Promise<Result> {
const order = await getOpenOrder(); // the amount comes from your server, never from the browser
const returnUrl = `${SITE}/pay/return?order=${order.id}`;
try {
const payment = await mollieClient.payments.create({
method: PaymentMethod.creditcard,
cardToken, // valid for 1 hour, one payment
amount: { currency: "EUR", value: order.total }, // a string with two decimals, e.g. "24.00"
description: `Order ${order.id}`,
redirectUrl: returnUrl,
webhookUrl: `${SITE}/api/mollie/webhook`, // must be reachable from the internet, not localhost
metadata: { orderId: order.id },
});
await savePaymentId(order.id, payment.id);
// _links.checkout: the 3-D Secure step. Mollie leaves it out when the payment needs no extra step.
return { ok: true, checkoutUrl: payment.getCheckoutUrl() ?? returnUrl };
} catch {
return { ok: false, message: "The payment could not be started. Nothing was charged." };
}
}
lib/mollie-js.ts
// Mollie.js ships as a script tag (https://js.mollie.com/v1/mollie.js) with no npm types,
// so these are the parts this page uses, written from Mollie's Mollie object and Component object docs.
export type MollieFieldType = "cardHolder" | "cardNumber" | "expiryDate" | "verificationCode";
// What Mollie passes to change listeners: the field has input (dirty), was focused once (touched), and an error text.
export type MollieFieldEvent = { dirty?: boolean; touched?: boolean; error?: string };
export interface MollieComponent {
mount(target: string | HTMLElement): void;
unmount(): void;
addEventListener(event: "change" | "focus" | "blur", callback: (event: MollieFieldEvent) => void): void;
}
export interface MollieInstance {
createComponent(type: MollieFieldType, options?: { styles?: { base?: Record<string, string> } }): MollieComponent;
createToken(): Promise<{ token?: string; error?: { message: string } }>;
}
declare global {
interface Window {
Mollie?: (profileId: string, options?: { locale?: string; testmode?: boolean }) => MollieInstance;
}
}
lib/mollie.ts
import "server-only";
import createMollieClient from "@mollie/api-client";
// test_... or live_... API key from the same Mollie profile as NEXT_PUBLIC_MOLLIE_PROFILE_ID
export const mollieClient = createMollieClient({ apiKey: process.env.MOLLIE_API_KEY! });
app/pay/return/page.tsx
import { PaymentStatus } from "@mollie/api-client";
import { mollieClient } from "@/lib/mollie";
import { getPaymentId } from "@/lib/orders";
// Mollie sends the buyer back to redirectUrl without a status, so read it from Mollie.
export default async function Return({ searchParams }: { searchParams: Promise<{ order?: string }> }) {
const { order } = await searchParams;
const paymentId = order ? await getPaymentId(order) : null;
if (!paymentId) return <p>We could not find that order.</p>;
const payment = await mollieClient.payments.get(paymentId);
if (payment.status === PaymentStatus.paid) return <p>Paid. Thank you, a receipt is on its way.</p>;
if (payment.status === PaymentStatus.open || payment.status === PaymentStatus.pending)
return <p>Your bank is still confirming the payment. This page will be right in a minute.</p>;
return <p>The payment did not go through ({payment.status}). Nothing was charged. Try again.</p>;
}
app/api/mollie/webhook/route.ts
import { PaymentStatus } from "@mollie/api-client";
import { mollieClient } from "@/lib/mollie";
import { markPaid } from "@/lib/orders";
// Mollie posts only id=tr_... (form encoded). Fetch the payment to learn its status: that fetch is the check.
export async function POST(req: Request) {
const form = await req.formData();
const id = form.get("id");
if (typeof id !== "string") return new Response(null, { status: 200 });
try {
const payment = await mollieClient.payments.get(id);
const orderId = (payment.metadata as { orderId?: string } | null)?.orderId;
if (orderId && payment.status === PaymentStatus.paid) await markPaid(orderId);
} catch {
// unknown id: still answer 200, so nobody learns which ids exist
}
return new Response(null, { status: 200 });
}
lib/orders.ts
import "server-only";
// Stand-ins for your database. Keep the Mollie payment id next to your order.
type Order = { id: string; total: string; paymentId?: string; paid?: boolean };
const orders = new Map<string, Order>();
export async function getOpenOrder(): Promise<Order> {
const order = { id: crypto.randomUUID(), total: "24.00" }; // read the cart and its total here
orders.set(order.id, order);
return order;
}
export async function savePaymentId(orderId: string, paymentId: string) {
const order = orders.get(orderId);
if (order) order.paymentId = paymentId;
}
export async function getPaymentId(orderId: string) {
return orders.get(orderId)?.paymentId ?? null;
}
export async function markPaid(orderId: string) {
const order = orders.get(orderId);
if (order && !order.paid) order.paid = true; // ship once, even if the webhook comes twice
}