How to Integrate Stripe Payment Gateway in React and Next.js (2026 Complete Guide) | WovLab
Stripe remains the gold standard for payment integration in React and Next.js applications. It has the best JavaScript SDK, the most comprehensive documentation, and the most developer-friendly API in the industry. But integrating it correctly — with proper security, error handling, and production readiness — takes more than following a quick tutorial.
This guide covers everything: from initial Stripe account setup to a production-ready payment flow in Next.js 15, including Stripe Elements, server-side PaymentIntents, webhooks, and subscriptions.
Note: WovLab integrates Stripe (and PhonePe Business for Indian customers) into your React/Next.js project free. Learn about our free gateway setup service.
Why Stripe for React/Next.js?
- @stripe/stripe-js — Official client-side library with Stripe Elements
- @stripe/react-stripe-js — React wrapper with hooks and components
- PCI compliance handled automatically via Stripe-hosted Elements
- Supports 135+ currencies, 40+ payment methods
- Built-in 3D Secure / Strong Customer Authentication (SCA)
- Excellent TypeScript support
Architecture Overview
A secure Stripe integration in Next.js follows this flow:
- Server: Create a PaymentIntent (sets amount, currency, metadata)
- Client: Display Stripe Elements (hosted card form)
- Client: Confirm payment with client_secret from server
- Stripe: Processes the payment, handles 3DS if needed
- Server: Webhook receives payment_intent.succeeded event
- Server: Fulfill the order
The cardinal rule: Never process payments purely client-side. All amount calculations and order fulfillment must happen server-side.
Step 1: Install Dependencies
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
Store your keys in environment variables:
# .env.local
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
Never commit secret keys. Add .env.local to .gitignore.
Step 2: Create the PaymentIntent (Server)
In Next.js 15 with App Router, create an API route:
// app/api/create-payment-intent/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-06-20',
});
export async function POST(req: NextRequest) {
try {
const { amount, currency = 'inr', metadata } = await req.json();
if (!amount || amount < 50) { // Min ₹0.50
return NextResponse.json({ error: 'Invalid amount' }, { status: 400 });
}
const paymentIntent = await stripe.paymentIntents.create({
amount: Math.round(amount), // In paise (INR) or cents
currency,
automatic_payment_methods: { enabled: true },
metadata: { ...metadata },
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret
});
} catch (err: any) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
Step 3: Build the Checkout Component
// components/CheckoutForm.tsx
'use client';
import { useState } from 'react';
import {
PaymentElement,
useStripe,
useElements,
} from '@stripe/react-stripe-js';
export default function CheckoutForm({ amount }: { amount: number }) {
const stripe = useStripe();
const elements = useElements();
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!stripe || !elements) return;
setIsLoading(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: `${window.location.origin}/payment-success`,
},
});
if (error) {
setMessage(error.message ?? 'An error occurred');
}
setIsLoading(false);
};
return (
<form onSubmit={handleSubmit}>
<PaymentElement options={{ layout: 'tabs' }} />
{message && <div className="text-red-500 mt-2">{message}</div>}
<button
disabled={isLoading || !stripe}
className="w-full mt-4 bg-blue-600 text-white py-3 rounded-lg font-semibold disabled:opacity-50"
>
{isLoading ? 'Processing...' : `Pay ₹${(amount/100).toFixed(2)}`}
</button>
</form>
);
}
Step 4: Wrap with Stripe Provider
// app/checkout/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { loadStripe } from '@stripe/stripe-js';
import { Elements } from '@stripe/react-stripe-js';
import CheckoutForm from '@/components/CheckoutForm';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export default function CheckoutPage() {
const [clientSecret, setClientSecret] = useState('');
const amount = 99900; // ₹999.00 in paise
useEffect(() => {
fetch('/api/create-payment-intent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount }),
})
.then((r) => r.json())
.then((data) => setClientSecret(data.clientSecret));
}, []);
if (!clientSecret) return <div>Loading...</div>;
return (
<Elements
stripe={stripePromise}
options={{ clientSecret, appearance: { theme: 'night' } }}
>
<CheckoutForm amount={amount} />
</Elements>
);
}
Step 5: Handle the Success Page
// app/payment-success/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe(
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
);
export default function PaymentSuccessPage() {
const searchParams = useSearchParams();
const [status, setStatus] = useState<string>('loading');
useEffect(() => {
const paymentIntentClientSecret =
searchParams.get('payment_intent_client_secret');
if (!paymentIntentClientSecret) {
setStatus('error');
return;
}
stripePromise.then((stripe) => {
stripe?.retrievePaymentIntent(paymentIntentClientSecret)
.then(({ paymentIntent }) => {
setStatus(paymentIntent?.status ?? 'unknown');
});
});
}, [searchParams]);
if (status === 'loading') return <p>Verifying payment...</p>;
if (status === 'succeeded') return <p>✅ Payment successful! Thank you.</p>;
return <p>Payment status: {status}</p>;
}
Step 6: Implement Webhooks (Critical for Production)
Webhooks are essential — they notify your server when a payment succeeds, even if the user closed their browser after paying.
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';
import { NextRequest, NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('stripe-signature')!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err: any) {
console.error('Webhook signature failed:', err.message);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
switch (event.type) {
case 'payment_intent.succeeded': {
const pi = event.data.object as Stripe.PaymentIntent;
// ✅ Fulfill the order here
await fulfillOrder(pi.metadata.orderId, pi.amount);
break;
}
case 'payment_intent.payment_failed': {
const pi = event.data.object as Stripe.PaymentIntent;
// Handle failure - notify user, release inventory, etc.
await handlePaymentFailed(pi.metadata.orderId);
break;
}
}
return NextResponse.json({ received: true });
}
Testing Webhooks Locally
# Install Stripe CLI
# On macOS:
brew install stripe/stripe-cli/stripe
# Login and forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Step 7: Stripe Subscriptions in Next.js
For SaaS applications with recurring billing:
// Create a subscription
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: 'price_monthly_plan_id' }],
payment_settings: {
payment_method_types: ['card'],
save_default_payment_method: 'on_subscription',
},
expand: ['latest_invoice.payment_intent'],
});
const invoice = subscription.latest_invoice as Stripe.Invoice;
const paymentIntent = invoice.payment_intent as Stripe.PaymentIntent;
return { clientSecret: paymentIntent.client_secret };
Advanced: Smart Routing (PhonePe + Stripe)
For businesses serving both Indian and international customers, implement smart gateway routing based on user location:
// Detect user's preferred payment method
async function selectGateway(userCountry: string, paymentMethod: string) {
if (userCountry === 'IN' && paymentMethod === 'upi') {
return 'phonepe';
}
if (userCountry === 'IN' && ['card', 'netbanking'].includes(paymentMethod)) {
return Math.random() > 0.3 ? 'phonepe' : 'stripe'; // 70/30 split
}
return 'stripe'; // International
}
Production Checklist
- ☐ Switch from test keys to live keys in production environment
- ☐ Set up webhook endpoint in Stripe Dashboard (Production)
- ☐ Verify webhook signature on every incoming event
- ☐ Use idempotency keys for payment creation
- ☐ Implement proper error handling and user feedback
- ☐ Test 3D Secure flow with Stripe test cards
- ☐ Set up Stripe Radar rules for fraud prevention
- ☐ Enable email receipts in Stripe Dashboard
- ☐ Configure refund workflow
- ☐ Set up Stripe Tax (if applicable)
Common Errors and Solutions
Error: "No such payment_intent" on success page
The payment_intent_client_secret URL parameter is only valid for the session. Ensure you're reading it immediately on page load and not after navigating away.
Error: "Webhook signature verification failed"
Ensure you're reading the raw request body (not parsed JSON) when calling constructEvent. In Next.js App Router, use await req.text() not await req.json().
Error: "Your card was declined"
During testing, use Stripe's test card numbers: 4242 4242 4242 4242 (success), 4000 0000 0000 9995 (insufficient funds), 4000 0000 0000 3220 (3DS required).