How to build a 3D Secure payment flow in a Next.js store
Published
Short answer
Start the payment on the server, send the customer to the bank verification page using the address your payment provider returns, and when they come back, confirm on the server that the payment really completed by asking the provider. Never trust the result that comes back through the browser. At Harmoni Yazılım I integrated QNBPay for payment collection and passed the 3D Secure flow to the frontend.
What 3D Secure is and how the flow works
3D Secure is an extra step where the bank that issued the card verifies the cardholder, usually with an SMS code or an approval in the banking app. Many payment providers and banks require it for online card payments, so a checkout has to be designed around it.
The defining feature is that the customer leaves your site for the bank's page and then returns. That is why you must learn the payment result from your server, not from the browser.
The flow, step by step
- The customer submits the payment form. Card data goes to the provider's own field or API, never through your server.
- Your server starts the payment with the provider and stores the order as pending in your database.
- The provider returns a redirect URL or an auto-submitting HTML form.
- The frontend sends the customer there with a full-page redirect, and the bank verification page opens.
- The customer completes verification. The bank returns to the provider, and the provider returns to your callback URL.
- Your callback asks the provider for the real payment status on the server and updates the order.
- The customer is redirected to the order result page.
What to build in Next.js
Write the start and callback steps as Route Handlers. The callback may be called with a POST from the bank, so redirect to a result page with a 303, which stops a page refresh from processing the payment twice. The example below is a provider-neutral skeleton.
// app/api/payments/callback/route.ts
export async function POST(request: Request) {
const form = await request.formData();
const orderId = String(form.get("orderId") ?? "");
// Never trust the posted result. Ask the provider for the real status.
const payment = await provider.getPayment(orderId);
if (payment.status === "paid") {
await markOrderPaid(orderId, payment.id);
}
return Response.redirect(new URL(`/orders/${orderId}`, request.url), 303);
}Common mistakes
- Treating the result in the URL or form body as proof and marking the order paid.
- Ignoring customers who abandon verification. Pending orders need a timeout and a way to retry.
- Opening the verification page in an iframe. Many bank pages refuse to load in one, so a full-page redirect is more compatible.
- Letting the callback run twice for the same payment. Update the order based on its state and make the operation safe to repeat.
- Storing the card number or CVV on your server or in your logs.
Questions
- Should 3D Secure use an iframe or a redirect?
- A full-page redirect. Many bank verification pages do not allow themselves to load inside an iframe.
- Should the frontend confirm that the payment succeeded?
- No. Confirm it on the server by asking the payment provider, and have the frontend only display the result.
- Should I store card details on my server?
- No. Card data goes to the payment provider. You only store the payment and order IDs the provider gives you.
I do this kind of work
Written by Mert Yılmaz, freelance fullstack developer.