React Stripe Integration: Accept Payments Securely
Payment processing is the most security-sensitive part of an ecommerce store. Stripe is the industry standard for accepting credit cards because it handles PCI compliance, fraud detection, and payment security without exposing card data to your servers. Building Stripe integration in React requires using Stripe's client-side libraries (Stripe.js and React Stripe.js), creating a payment intent on your backend, and handling webhook notifications for order confirmation. This article shows you how to implement secure payment processing that protects both your business and your customers. You'll learn why Stripe is used by 1 million+ businesses globally, how to handle payment failures gracefully, and how to test in Stripe's sandbox environment.
Why Stripe Instead of Handling Cards Yourself
Never process raw card data on your servers. Stripe, Square, and other payment processors exist for a reason: they're PCI DSS compliant, which means they meet strict security standards set by Visa, Mastercard, and American Express. Handling card data yourself requires annual security audits, encryption, and intricate compliance work—it costs tens of thousands per year. Stripe takes that burden and charges a simple 2.9% + $0.30 per transaction. This is a bargain for security and peace of mind.
Stripe also provides fraud detection, 3D Secure authentication, and automatic reconciliation, which would take months to build in-house.
Install Stripe Libraries
npm install @stripe/react-stripe-js @stripe/js
Initialize Stripe in your main App.jsx:
import { Elements } from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/js';
const stripePromise = loadStripe(
import.meta.env.VITE_STRIPE_PUBLIC_KEY
);
export default function App() {
return (
<Elements stripe={stripePromise}>
{/* Routes */}
</Elements>
);
}
Your VITE_STRIPE_PUBLIC_KEY is a publishable key from your Stripe Dashboard. It's safe to expose to the frontend; never expose the secret key.
Create a Payment Intent on Your Backend
A payment intent is Stripe's way of saying "charge this card this amount." Create one on your backend before the user enters their card details. In your Node.js/Express backend:
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/api/payment/create-intent', async (req, res) => {
const { amount, cartId, orderId } = req.body;
try {
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount * 100), // Stripe uses cents
currency: 'usd',
metadata: { cartId, orderId },
// Optional: prevent duplicate charges
idempotency_key: `${orderId}_${Date.now()}`,
});
res.json({
clientSecret: paymentIntent.client_secret,
paymentIntentId: paymentIntent.id,
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
The clientSecret is returned to the frontend and used to confirm the payment. Stripe prevents duplicate charges with idempotency_key.
Build the Payment Form Component
Create src/components/PaymentForm.jsx:
import { useState } from 'react';
import {
CardElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
import axios from 'axios';
export default function PaymentForm({ orderId, amount, onSuccess, onError }) {
const stripe = useStripe();
const elements = useElements();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const handleSubmit = async (e) => {
e.preventDefault();
if (!stripe || !elements) {
setError('Payment system not ready');
return;
}
setLoading(true);
setError(null);
try {
// 1. Create payment intent on backend
const intentResponse = await axios.post(
`${import.meta.env.VITE_API_URL}/payment/create-intent`,
{ amount, orderId, cartId: localStorage.getItem('cartId') }
);
const { clientSecret, paymentIntentId } = intentResponse.data;
// 2. Confirm payment with card details
const result = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: elements.getElement(CardElement),
billing_details: {
// Optionally collect address, name, etc.
},
},
});
if (result.error) {
setError(result.error.message);
onError(result.error.message);
} else if (result.paymentIntent.status === 'succeeded') {
// 3. Payment successful
onSuccess({
paymentIntentId,
status: 'succeeded',
});
}
} catch (error) {
setError(error.message);
onError(error.message);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="bg-white p-4 border border-gray-300 rounded">
<CardElement
options={{
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#fa755a',
},
},
}}
/>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<button
type="submit"
disabled={!stripe || loading}
className="w-full bg-blue-600 text-white py-3 rounded font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{loading ? 'Processing...' : `Pay $${amount.toFixed(2)}`}
</button>
</form>
);
}
The CardElement securely collects card data without your server ever touching it. Stripe tokenizes the card and your backend only sees a token.
Handle Webhook Events for Order Confirmation
Stripe sends webhook events to notify you when payments succeed, fail, or require authentication. Create a webhook endpoint:
// backend/routes/webhooks.js
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const express = require('express');
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
return res.status(400).send(`Webhook Error: ${error.message}`);
}
// Handle specific events
switch (event.type) {
case 'payment_intent.succeeded': {
const paymentIntent = event.data.object;
const { orderId } = paymentIntent.metadata;
// Mark order as paid
await db.query(
'UPDATE orders SET status = $1, payment_intent_id = $2 WHERE id = $3',
['paid', paymentIntent.id, orderId]
);
// Send confirmation email
await sendOrderConfirmationEmail(orderId);
break;
}
case 'payment_intent.payment_failed': {
const paymentIntent = event.data.object;
const { orderId } = paymentIntent.metadata;
// Mark order as failed, offer retry
await db.query(
'UPDATE orders SET status = $1 WHERE id = $2',
['payment_failed', orderId]
);
break;
}
case 'charge.refunded': {
const charge = event.data.object;
const paymentIntentId = charge.payment_intent;
// Mark order as refunded
await db.query(
'UPDATE orders SET status = $1 WHERE payment_intent_id = $2',
['refunded', paymentIntentId]
);
break;
}
}
res.json({ received: true });
});
In your Stripe Dashboard, register your webhook endpoint (e.g., https://yourdomain.com/webhooks/stripe) and subscribe to payment_intent.succeeded, payment_intent.payment_failed, and charge.refunded events.
Test with Stripe Test Keys
Stripe provides test card numbers for sandbox testing:
| Card Number | Result |
|---|---|
| 4242 4242 4242 4242 | Successful payment |
| 4000 0000 0000 0002 | Declined |
| 3782 822463 10005 | Requires 3D Secure |
| 5200 0283 2000 0007 | Mastercard (succeeds) |
Use expiration date 12/25 and any CVC. Payments in sandbox don't affect real accounts.
Error Handling and Retries
Payment failures are common (insufficient funds, expired card, etc.). Implement a retry mechanism:
const [retryCount, setRetryCount] = useState(0);
const handlePaymentFailure = async (error) => {
if (retryCount < 2) {
setRetryCount(retryCount + 1);
// Show "Retry" button
} else {
// Offer alternative payment methods or support contact
}
};
Store failed payment attempts in your database for debugging and customer support.
Key Takeaways
- Never handle raw card data; always use Stripe or similar PCI-compliant processors.
- Create payment intents on your backend before the frontend collects card details.
- Use Stripe.confirmCardPayment() to securely confirm payments with card tokens.
- Handle webhook events (payment_intent.succeeded, payment_intent.payment_failed) to update order status.
- Test thoroughly with Stripe's sandbox keys before going live; use test card numbers.
Frequently Asked Questions
What if a customer's payment requires 3D Secure authentication?
Stripe automatically detects when 3D Secure is required and prompts the user. Your confirmCardPayment() call handles the popup. No additional code needed.
How do I refund a customer?
In your Stripe Dashboard or via API:
const refund = await stripe.refunds.create({
payment_intent: paymentIntentId,
amount: refundAmount, // in cents, optional (full refund if omitted)
});
Send a webhook event and your database will update.
What if my webhook endpoint is down and Stripe can't deliver events?
Stripe retries webhooks for 3 days. In the meantime, poll the Stripe API for payment intent updates. Always implement a recovery mechanism so no payment falls through the cracks.
How do I handle currency conversions for international customers?
Stripe supports 130+ currencies. Specify the currency in paymentIntents.create(): currency: 'eur' for euros. Stripe converts at real-time rates.