React Component
A ready-to-use React component for integrating Merso Modal into your Next.js or React application.
Overview
The MersoModal component provides a clean, type-safe way to embed the payment modal in React applications. It handles all the complexity of iframe communication, form submission, and event handling.
Secure POST
JWT sent via POST body, not URL
Event Callbacks
TypeScript callbacks for all events
Flexible Sizing
Customizable width and height
Auto-Submit
Form submits on component mount
MersoModal.tsx
Copy this component into your React/Next.js project:
"use client";
import React, { useEffect, useRef, useId } from "react";
interface MersoModalProps {
modalServiceUrl?: string;
jwtToken: string;
productData?: Record<string, string>;
onPaymentSuccess?: (data: {
paymentIntentId: string;
firstPaymentAmount?: string;
weeklyPaymentAmount?: string;
totalAmount?: string;
}) => void;
onPaymentCancelled?: () => void;
onModalClose?: () => void;
productType?: "Item" | "NFT";
paymentMode?: "BNPL" | "UPFRONT";
// Style options
width?: number | string;
height?: number | string;
}
const MersoModal: React.FC<MersoModalProps> = ({
modalServiceUrl = "https://modal.dev.merso.io",
jwtToken,
productData,
onPaymentSuccess,
onPaymentCancelled,
onModalClose,
productType,
paymentMode,
width,
height,
}) => {
const iframeRef = useRef<HTMLIFrameElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const hasSubmittedRef = useRef(false);
const iframeName = useId().replace(/:/g, "_");
// Build product data for POST request
const getProductDataFields = (): Record<string, string> => {
const fields: Record<string, string> = {
jwt_token: jwtToken, // JWT sent securely via POST body
};
// Product type determines which data to send
if (productType) {
fields.product_type = productType.toUpperCase();
}
// Optional: Pre-select payment mode
if (paymentMode) fields.payment_mode = paymentMode;
if (productData) {
Object.entries(productData).forEach(([key, value]) => {
fields[key] = value;
});
}
return fields;
};
// Submit form to iframe on mount
useEffect(() => {
if (formRef.current && !hasSubmittedRef.current) {
hasSubmittedRef.current = true;
formRef.current.submit();
}
}, []);
// Listen for postMessage from iframe
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.origin === "https://js.stripe.com") return;
const expectedTypes = [
"payment-success",
"payment-cancelled",
"crypto-payment-success",
"crypto-payment-error",
"modal-close",
];
if (!event.data?.type || !expectedTypes.includes(event.data.type)) return;
if (event.origin !== modalServiceUrl) {
console.warn("Message from unexpected origin:", event.origin);
return;
}
if (event.data?.type === "payment-success") {
onPaymentSuccess?.({
paymentIntentId: event.data.paymentIntentId,
firstPaymentAmount: event.data.firstPaymentAmount,
weeklyPaymentAmount: event.data.weeklyPaymentAmount,
totalAmount: event.data.totalAmount,
});
} else if (event.data?.type === "payment-cancelled") {
onPaymentCancelled?.();
} else if (event.data?.type === "crypto-payment-success") {
onPaymentSuccess?.({
paymentIntentId: event.data.transactionHash || "crypto-payment",
});
} else if (event.data?.type === "modal-close") {
onModalClose?.();
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [modalServiceUrl, onPaymentSuccess, onPaymentCancelled, onModalClose]);
const productDataFields = getProductDataFields();
return (
<>
{/* Hidden form that POSTs to the iframe */}
<form
ref={formRef}
action={`${modalServiceUrl}/modal`}
method="POST"
target={iframeName}
style={{ display: "none" }}
>
{Object.entries(productDataFields).map(([key, value]) => (
<input key={key} type="hidden" name={key} value={value} />
))}
</form>
{/* Iframe receives POST data */}
<iframe
ref={iframeRef}
name={iframeName}
title="Merso Payment Modal"
allow="payment *; publickey-credentials-get *; publickey-credentials-create *; fullscreen *"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-top-navigation-by-user-activation allow-modals"
// @ts-ignore - React requires lowercase for this HTML attribute
allowtransparency="true"
scrolling="no"
style={{
width: width ? `calc(${typeof width === 'number' ? `${width}px` : width} * 1.1)` : "100%",
height: height ? `calc(${typeof height === 'number' ? `${height}px` : height} * 1.1)` : "100%",
border: "none",
overflow: "hidden",
background: "transparent",
}}
/>
</>
);
};
export default MersoModal;
Props Reference
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
jwtToken |
string |
✅ Yes | — | Your JWT token from the /auth endpoint |
productType |
"Item" | "NFT" |
No | — | Type of product being purchased |
paymentMode |
"BNPL" | "UPFRONT" |
No | — | Pre-select a payment mode |
productData |
Record<string, string> |
No | — | Additional product data (item_id, item_price, etc.) |
modalServiceUrl |
string |
No | https://modal.dev.merso.io |
Base URL of the modal service |
onPaymentSuccess |
function |
No | — | Callback when payment succeeds |
onPaymentCancelled |
function |
No | — | Callback when user cancels |
onModalClose |
function |
No | — | Callback when user clicks Close after successful payment |
width |
number | string |
No | "100%" |
Iframe width (e.g., 480 or "480px") |
height |
number | string |
No | "100%" |
Iframe height (e.g., 600 or "600px") |
Usage Examples
Basic Item Purchase
import MersoModal from "./components/MersoModal";
function CheckoutPage() {
const handleSuccess = (data) => {
console.log("Payment successful!", data.paymentIntentId);
// Redirect to success page or update UI
};
const handleCancel = () => {
console.log("Payment cancelled");
// Handle cancellation
};
return (
<div style={{ width: 480, height: 600 }}>
<MersoModal
jwtToken="your-jwt-token"
productType="Item"
productData={{
item_id: "123",
item_name: "Premium Sword",
item_price: "29.99",
player_email: "player@example.com",
}}
onPaymentSuccess={handleSuccess}
onPaymentCancelled={handleCancel}
width={480}
height={600}
/>
</div>
);
}
NFT Purchase
<MersoModal
jwtToken={jwtToken}
productType="NFT"
paymentMode="BNPL"/"UPFRONT"
productData={{
wallet_address: "0x1234...abcd",
collection_address: "0xNFT...collection",
token_id: "1",
token_price: "0.05",
token_name: "Epic NFT #1",
user_email: "user@example.com",
chain_id: "1",
}}
onPaymentSuccess={(data) => {
console.log("NFT purchased with BNPL!");
console.log("First payment:", data.firstPaymentAmount);
console.log("Weekly:", data.weeklyPaymentAmount);
}}
/>
Modal Overlay Pattern
import { useState } from "react";
import MersoModal from "./components/MersoModal";
function GameShop() {
const [showModal, setShowModal] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
const [paymentData, setPaymentData] = useState(null);
const openCheckout = (item) => {
setSelectedItem(item);
setShowModal(true);
};
return (
<>
<button onClick={() => openCheckout({ id: "123", name: "Sword", price: "29.99" })}>
Buy Sword - $29.99
</button>
{showModal && (
<div style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}>
<div style={{ width: 480, height: 600, borderRadius: "1rem", overflow: "hidden" }}>
<MersoModal
jwtToken={jwtToken}
productType="Item"
productData={{
item_id: selectedItem.id,
item_name: selectedItem.name,
item_price: selectedItem.price,
}}
onPaymentSuccess={(data) => {
// Store payment data for use after modal closes
setPaymentData(data);
console.log("Payment successful!", data);
}}
onPaymentCancelled={() => setShowModal(false)}
onModalClose={() => {
// User clicked Close after seeing success screen
setShowModal(false);
if (paymentData) {
alert(`Purchase complete! ID: ${paymentData.paymentIntentId}`);
}
}}
width="100%"
height="100%"
/>
</div>
</div>
)}
</>
);
}
How It Works
This component uses a hidden form that POSTs data directly to the iframe. This is more secure as the JWT token and product data aren't exposed in the URL.
The Flow:
- Mount: Component creates a hidden form with all product data as hidden inputs
- Submit: On mount, the form auto-submits targeting the iframe by name
- Load: Iframe receives POST data and renders the payment modal
- Interact: User completes payment in the iframe
- Success: Modal shows success screen with payment details
- Message: Modal sends
payment-successpostMessage with payment data - Close: User clicks Close button, modal sends
modal-closepostMessage - Callback: Component calls onPaymentSuccess, then onModalClose to close the modal
// Unique iframe name using React's useId hook
const iframeName = useId().replace(/:/g, "_");
// Prevent double-submission with ref
const hasSubmittedRef = useRef(false);
// Form targets iframe by name
<form target={iframeName} method="POST" action="/modal">
<input type="hidden" name="jwt_token" value={jwtToken} />
...
</form>
<iframe name={iframeName} ... />
Security Considerations
Always fetch your JWT token from a secure backend endpoint. Never commit tokens to your codebase or expose your API key in client-side code.
// pages/api/merso-token.ts (Next.js API Route)
export async function POST(req: Request) {
const response = await fetch("https://modal.dev.merso.io/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
game_id: process.env.MERSO_GAME_ID,
api_key: process.env.MERSO_API_KEY,
}),
});
const data = await response.json();
return Response.json({ token: data.authResult.token });
}
// Client component
const [jwtToken, setJwtToken] = useState("");
useEffect(() => {
fetch("/api/merso-token", { method: "POST" })
.then(res => res.json())
.then(data => setJwtToken(data.token));
}, []);
Iframe Sandbox Attributes
The component uses restrictive sandbox attributes for security:
allow-scripts- Required for modal functionalityallow-same-origin- Required for Stripe integrationallow-forms- Required for payment formsallow-popups- Required for 3D Secure authenticationallow-popups-to-escape-sandbox- Required for external popup windowsallow-top-navigation-by-user-activation- Required for certain redirect flowsallow-modals- Required for confirmation dialogs
Callback Data
onPaymentSuccess Data
interface PaymentSuccessData {
paymentIntentId: string; // Stripe Payment Intent ID
firstPaymentAmount?: string; // BNPL: First payment amount
weeklyPaymentAmount?: string; // BNPL: Weekly installment
totalAmount?: string; // Total purchase amount
}
Event Types Handled
payment-success- Fiat payment completed (Stripe)payment-cancelled- User cancelled the paymentcrypto-payment-success- Crypto/NFT payment completedcrypto-payment-error- Crypto payment failedmodal-close- User clicked Close button after successful payment