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.
Table of Contents
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.