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.
Stripe Payments in React: 3D Secure, Apple Pay, and Surviving Network Drops
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.
Accelerate your Web Development & Payments Modernization Roadmap
Need custom architecture auditing, automated OpenAPI contract generation, or zero-downtime microservice migration guidance for your engineering team?
Frequently Asked Questions
Do I need an ephemeral key to use Stripe in a React web app?
No. Ephemeral keys exist because mobile SDKs (iOS/Android/React Native) need to let the native Customer sheet fetch and manage saved payment methods without exposing your secret key. On the web, the Payment Element reads everything it needs from the PaymentIntent's client secret, so you can skip ephemeral key generation entirely.
Does confirmPayment() mean the payment succeeded?
Not by itself. It only tells you whether the confirmation request hit an immediate error, like a declined card. Real success, including anything that went through a 3D Secure redirect, has to be confirmed by your backend listening for the payment_intent.succeeded webhook.
Subscribe to RenovateAPI
Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.
Discussion (2)
Extremely helpful breakdown of the Strangler Fig pattern! We're currently refactoring a legacy Java monolith at work and the OpenAPI gateway routing tips saved us weeks of experimentation.
The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.
Suggested Related Articles
Stripe Webhooks in NestJS Are Fine Until Production Hits: Fixing Race Conditions and Duplicate Events
How to stop Stripe webhooks from double-charging users, showing stale subscription status, or racing the frontend redirect in a NestJS and PostgreSQL app.
Why Your NestJS API Slows Down Under Load (And It's Probably Not the Database)
A field guide to diagnosing V8 memory leaks and event loop lag in high-throughput NestJS APIs — measuring lag with perf_hooks, killing RxJS subscription leaks, and offloading CPU work to worker threads.
Your Vite Bundle Is Probably 1.5MB for No Good Reason
Fix slow LCP and INP in React + Vite apps with route-level code splitting, manual Rollup chunks, and smarter icon imports. Real config included.