React Stripe Integration: Payments and Subscriptions (2026)

Stripe is the standard payment infrastructure for SaaS and e-commerce. The frontend receives a PaymentIntent client secret from your backend, mounts Stripe Elements to collect card details, and confirms the payment — card numbers never touch your server. This guide covers one-time payments with PaymentElement, Stripe Checkout redirect, subscription billing with webhooks, and Next.js Server Actions integration.

Setup

npm install @stripe/react-stripe-js @stripe/stripe-js

// src/lib/stripe.ts — load Stripe once, outside components
import { loadStripe } from '@stripe/stripe-js'

// Publishable key — safe to expose in frontend code
export const stripePromise = loadStripe(
  process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!
)

// Never put your secret key in frontend code.
// Secret key lives only on the server (API routes / Server Actions).

One-Time Payment

// Step 1: Server creates a PaymentIntent and returns client_secret
// api/create-payment-intent.ts (Next.js Route Handler)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: Request) {
  const { amount, currency = 'usd' } = await req.json()
  const paymentIntent = await stripe.paymentIntents.create({
    amount,      // In smallest currency unit (cents)
    currency,
    automatic_payment_methods: { enabled: true },
  })
  return Response.json({ clientSecret: paymentIntent.client_secret })
}

// Step 2: Frontend wraps the form in Elements with the client secret
import { Elements } from '@stripe/react-stripe-js'
import { stripePromise } from '@/lib/stripe'

function CheckoutPage({ amount }: { amount: number }) {
  const [clientSecret, setClientSecret] = useState<string | null>(null)

  useEffect(() => {
    fetch('/api/create-payment-intent', {
      method: 'POST',
      body: JSON.stringify({ amount }),
      headers: { 'Content-Type': 'application/json' },
    })
      .then(r => r.json())
      .then(d => setClientSecret(d.clientSecret))
  }, [amount])

  if (!clientSecret) return <Spinner />

  return (
    <Elements
      stripe={stripePromise}
      options={{
        clientSecret,
        appearance: {
          theme: 'night',
          variables: { colorPrimary: '#6366f1', borderRadius: '8px' },
        },
      }}
    >
      <PaymentForm amount={amount} />
    </Elements>
  )
}

// Step 3: PaymentElement form
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'

function PaymentForm({ amount }: { amount: number }) {
  const stripe = useStripe()
  const elements = useElements()
  const [error, setError] = useState<string | null>(null)
  const [processing, setProcessing] = useState(false)

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault()
    if (!stripe || !elements) return

    setProcessing(true)

    const { error } = await stripe.confirmPayment({
      elements,
      confirmParams: {
        return_url: `${window.location.origin}/payment-success`,
      },
    })

    if (error) {
      setError(error.message ?? 'Payment failed')
      setProcessing(false)
    }
    // On success, Stripe redirects to return_url
  }

  return (
    <form onSubmit={handleSubmit}>
      <PaymentElement />
      {error && <p role="alert" style={{ color: '#ef4444' }}>{error}</p>}
      <button type="submit" disabled={!stripe || processing}>
        {processing ? 'Processing...' : `Pay $${(amount / 100).toFixed(2)}`}
      </button>
    </form>
  )
}

Stripe Checkout Redirect

// Hosted Checkout — simplest approach, Stripe handles the full UI
// Server creates a Checkout Session
export async function POST(req: Request) {
  const { priceId, customerId } = await req.json()

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',            // or 'subscription' for recurring
    customer: customerId,       // optional — pre-fill customer details
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/cancel`,
    // Collect billing address
    billing_address_collection: 'required',
    // Apply tax automatically
    automatic_tax: { enabled: true },
  })

  return Response.json({ url: session.url })
}

// Frontend redirects to hosted Checkout page
async function handleCheckout(priceId: string) {
  const { url } = await fetch('/api/checkout', {
    method: 'POST',
    body: JSON.stringify({ priceId }),
    headers: { 'Content-Type': 'application/json' },
  }).then(r => r.json())

  window.location.href = url  // Redirect to Stripe-hosted page
}

// Success page — retrieve session to confirm
// app/success/page.tsx
export default async function SuccessPage({ searchParams }) {
  const session = await stripe.checkout.sessions.retrieve(searchParams.session_id)
  return <h1>Payment confirmed! Amount: {session.amount_total / 100}</h1>
}

Subscriptions

// Create subscription — returns a PaymentIntent client_secret for first payment
export async function POST(req: Request) {
  const { customerId, priceId } = await req.json()

  const subscription = await stripe.subscriptions.create({
    customer: customerId,
    items: [{ price: priceId }],
    payment_behavior: 'default_incomplete',
    payment_settings: { 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 Response.json({
    subscriptionId: subscription.id,
    clientSecret: paymentIntent.client_secret,
  })
}

// Frontend confirms with the same PaymentElement flow as one-time payments
// The subscription activates after successful payment confirmation

// Cancel subscription
await stripe.subscriptions.update(subscriptionId, { cancel_at_period_end: true })
// Or immediately: await stripe.subscriptions.cancel(subscriptionId)

Webhooks

// Webhooks — server receives events when payment status changes
// Critical for subscriptions: payment_intent.succeeded doesn't mean
// the subscription is active. Listen to subscription events instead.

// api/webhooks/stripe/route.ts
import { headers } from 'next/headers'

export async function POST(req: Request) {
  const body = await req.text()
  const sig = headers().get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    return new Response('Webhook signature failed', { status: 400 })
  }

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated': {
      const sub = event.data.object as Stripe.Subscription
      await db.user.update({
        where: { stripeCustomerId: sub.customer as string },
        data: {
          subscriptionStatus: sub.status,           // 'active' | 'past_due' | 'canceled'
          subscriptionPlan: sub.items.data[0].price.lookup_key,
          subscriptionPeriodEnd: new Date(sub.current_period_end * 1000),
        },
      })
      break
    }
    case 'invoice.payment_failed': {
      // Email user about failed payment, show banner in app
      break
    }
    case 'customer.subscription.deleted': {
      // Revoke access
      break
    }
  }

  return new Response('OK')
}

# Test webhooks locally
stripe listen --forward-to localhost:3000/api/webhooks/stripe
stripe trigger customer.subscription.created

Next.js Server Actions

// app/actions/stripe.ts — no API route needed
'use server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function createCheckoutSession(priceId: string) {
  const user = await getServerSession()
  if (!user) throw new Error('Not authenticated')

  const session = await stripe.checkout.sessions.create({
    mode: 'subscription',
    customer_email: user.email,
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
    metadata: { userId: user.id },
  })

  redirect(session.url!)   // next/navigation redirect
}

// Client component — form action calls Server Action directly
function PricingCard({ plan }: { plan: Plan }) {
  return (
    <form action={createCheckoutSession.bind(null, plan.stripePriceId)}>
      <button type="submit">Subscribe to {plan.name}</button>
    </form>
  )
}
Never trust the frontend for payment amounts. Always create the PaymentIntent or Checkout Session on the server with the authoritative price from your database. A user could manipulate the amount sent from the browser. Only trust webhook events signed by Stripe to confirm payment completion — not the success URL redirect.