RA
RenovateAPIEngineering Hub
Web Development & Payments

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.

AAbhishek6 min read
Web Development & Payments6 min read

Stripe Payments in React: 3D Secure, Apple Pay, and Surviving Network Drops

RENOVATEAPI ARCHITECTURAL SPEC
CANONICAL GUIDE

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.

RenovateAPI Engineering Suite

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.

Weekly Engineering Dispatch

Subscribe to RenovateAPI

Get weekly architectural guides, API refactoring strategies, and technical SEO updates delivered directly to your inbox.

Discussion (2)

A
Alex Rivera
2 hours ago

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.

S
Sophia Chen
1 day ago

The schema JSON-LD and FAQ block structure really helps with indexing. Great technical detail on entity mentions too.

Suggested Related Articles