Stripe Payments in React: 3D Secure, Apple Pay, and Surviving Network Drops
How to wire Stripe's Payment Element into a React app so 3D Secure, Apple Pay, and Google Pay don't leave you with orphaned orders or duplicate charges.

Abhishek
Full-Stack & AI Product Engineer
Payment integration in a React app looks simple right up until the first customer's bank triggers a 3D Secure challenge, or their wifi drops the second they tap "Pay." Stripe's Payment Element handles most of the hard UI work for you, but the backend still has to be built like the client can disappear mid-transaction — because it will.
This is the architecture I use for wiring Stripe into a React frontend with a NestJS backend: PaymentIntents created server-side, 3D Secure handled by Stripe's own redirect flow, and order fulfillment driven entirely by webhooks instead of whatever the browser tells you happened.
Why client-only confirmation breaks in production
Three failure modes show up almost immediately once you have real traffic:
- 3D Secure drops. A bank triggers an SMS or biometric challenge, the user closes the tab or the modal mid-verification, and the PaymentIntent sits unconfirmed.
- Mid-payment network drops. The customer's connection dies after Stripe has processed the charge but before your frontend gets the response. If your fulfillment logic lives entirely in the
then()of a client-side confirm call, that order never ships. - Price tampering. If the amount is computed anywhere on the client, someone will eventually open dev tools and change it.
All three point to the same fix: the backend creates the PaymentIntent with the real amount, and the backend — not the browser — decides when an order is actually paid.
Step 1: Create the PaymentIntent on the backend
Don't calculate totals or touch the Stripe API from the client. Here's the NestJS service:
// src/billing/stripe-checkout.service.ts
import { Injectable } from '@nestjs/common';
import Stripe from 'stripe';
@Injectable()
export class StripeCheckoutService {
private stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
async createPaymentIntent(orderId: string, amountInCents: number) {
const paymentIntent = await this.stripe.paymentIntents.create(
{
amount: amountInCents,
currency: 'usd',
automatic_payment_methods: { enabled: true },
metadata: { orderId },
},
{ idempotencyKey: `order_checkout_${orderId}` },
);
return { clientSecret: paymentIntent.client_secret };
}
}
Worth calling out: there's no ephemeral key here. Ephemeral keys are a mobile-SDK concept — they let the native iOS/Android/React Native sheet manage a saved Customer's payment methods without shipping your secret key to the device. On the web, the client secret alone is enough for the Payment Element to do its job.
The idempotency key matters more than it looks like it does. If a slow network causes your checkout button to fire twice, Stripe returns the same PaymentIntent instead of creating a second charge.
Step 2: Mount the Payment Element in React
@stripe/react-stripe-js wraps Stripe.js in a set of hooks and an Elements provider. Fetch the client secret, wrap your form, and let the Payment Element render the actual card/wallet UI:
// src/pages/CheckoutPage.tsx
import { useEffect, useState } from 'react';
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import { CheckoutForm } from '../components/CheckoutForm';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export function CheckoutPage({ orderId, amount }: { orderId: string; amount: number }) {
const [clientSecret, setClientSecret] = useState<string | null>(null);
useEffect(() => {
fetch('https://api.yourdomain.com/payments/checkout-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId, amount }),
})
.then((res) => res.json())
.then((data) => setClientSecret(data.clientSecret));
}, [orderId, amount]);
if (!clientSecret) return <p>Loading checkout...</p>;
return (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckoutForm orderId={orderId} />
</Elements>
);
}
And the form itself:
// src/components/CheckoutForm.tsx
import { useState, FormEvent } from 'react';
import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js';
export function CheckoutForm({ orderId }: { orderId: string }) {
const stripe = useStripe();
const elements = useElements();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setLoading(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/checkout/complete?orderId=${orderId}`,
},
});
// You only land here on an immediate failure (card declined, expired, etc.).
// A successful confirmation, or one that needs 3DS, redirects the browser away.
if (error) {
setMessage(error.message ?? 'Payment failed. Try again.');
}
setLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button disabled={!stripe || loading}>
{loading ? 'Processing...' : 'Pay now'}
</button>
{message && <p role="alert">{message}</p>}
</form>
);
}
3D Secure and Apple Pay/Google Pay come for free
This is the part that used to take a week of custom modal logic. With automatic_payment_methods enabled on the PaymentIntent and the Payment Element mounted, Stripe decides at confirm-time whether a 3D Secure challenge is required, and if so, it redirects the browser to the bank's verification page and back to your return_url automatically. You don't write any 3DS-specific code.
Apple Pay and Google Pay show up the same way — the Payment Element detects wallet eligibility (HTTPS origin, registered domain for Apple Pay, supported browser) and renders the wallet button above the card fields on its own. No separate Payment Request Button wiring required unless you specifically want wallet buttons somewhere else on the page.
Step 3: Fulfill orders from the webhook, not the redirect
The /checkout/complete page the customer lands on after a 3DS redirect is for UX, not truth. Use it to show a status message by calling retrievePaymentIntent, but never grant access or ship product from that page load — a closed tab or a crashed browser after payment succeeds would silently lose the order.
// src/billing/stripe-webhook.controller.ts
import { Controller, Post, Req, Res, RawBodyRequest } from '@nestjs/common';
import { Request, Response } from 'express';
import Stripe from 'stripe';
@Controller('webhooks/stripe')
export class StripeWebhookController {
private stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
@Post()
async handleWebhook(@Req() req: RawBodyRequest<Request>, @Res() res: Response) {
const sig = req.headers['stripe-signature']!;
const event = this.stripe.webhooks.constructEvent(
req.rawBody!,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
);
if (event.type === 'payment_intent.succeeded') {
const intent = event.data.object as Stripe.PaymentIntent;
// Fulfill using intent.metadata.orderId — this is the only place
// an order should actually flip to "paid."
}
res.json({ received: true });
}
}
This is the one piece of the flow that's genuinely non-negotiable. Everything else — the redirect UX, the loading spinner, the "your order is confirmed" toast — is decoration. The webhook is the record of truth.
What changes vs. mobile
| Concern | React Native (native SDK) | React (web) |
|---|---|---|
| Payment UI | Native PaymentSheet component |
PaymentElement inside Elements |
| Customer session | Requires an ephemeral key | Not needed — client secret is enough |
| 3D Secure | Handled inside the native sheet | Handled via browser redirect to return_url |
| Apple/Google Pay | Configured via applePay/googlePay props |
Auto-detected by PaymentElement |
| Fulfillment | Webhook-driven (same) | Webhook-driven (same) |
The backend discipline — server-side PaymentIntent creation, idempotency keys, webhook-only fulfillment — doesn't change between platforms. Only the client SDK and how 3DS gets presented does.
The one opinion worth having
Skip building a custom card form unless you have a specific reason to. The Payment Element gets you 3DS, Apple Pay, Google Pay, and Stripe's fraud signals without extra code, and every hour spent on a hand-rolled card input is an hour not spent on the webhook and fulfillment logic that actually determines whether customers get charged correctly.
Related Articles
View All Articles ↗Stripe Webhook Signature Verification Keeps Failing in NestJS? Here's the Fix
Stripe webhook signatures fail in NestJS because the global body parser rewrites your JSON before verification runs. Here's how raw body capture fixes it, for Express and Fastify.
Building a Stripe Dunning System in NestJS That Actually Recovers Revenue
A NestJS architecture for Stripe subscription dunning that syncs Postgres state, handles 3D Secure retries, and recovers failed payments instead of just canceling users.
Stripe Dunning in NestJS: Stop Losing Subscribers to Failed Card Charges
A production dunning architecture for NestJS SaaS apps — grace periods, 3D Secure recovery emails, and webhook-driven state sync that recovers most failed subscription payments.