← Back to Blog

How to Integrate Stripe Payment Gateway in React and Next.js (2026 Complete Guide) | WovLab

By WovLab Team | February 24, 2026 | 6 min read

Stripe React Next.js Tutorial   February 17, 2026 · 22 min read

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?

Architecture Overview

A secure Stripe integration in Next.js follows this flow:

  1. Server: Create a PaymentIntent (sets amount, currency, metadata)
  2. Client: Display Stripe Elements (hosted card form)
  3. Client: Confirm payment with client_secret from server
  4. Stripe: Processes the payment, handles 3DS if needed
  5. Server: Webhook receives payment_intent.succeeded event
  6. 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

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).

Hosting Recommendation: Get reliable web hosting from Hostinger — trusted for business websites

Ready to Get Started?

Let WovLab handle it for you — zero hassle, expert execution.

💬 Chat on WhatsApp