Stripe remained the default payment processor for web applications through 2025 and into 2026. The API is stable, the documentation is excellent, and the SDK handles the hard parts — 3D Secure, local payment methods, tax calculation, and SCA compliance. This guide walks through the integration path that ships fastest and handles production edge cases correctly.
What changed in 2026
- Payment Element fully replaced the old separate CardElement, IdealElement, etc. — one component, 40+ payment methods.
- Stripe Tax moved out of beta; it calculates and collects sales tax / VAT automatically on Checkout and Subscriptions.
- Radar (fraud detection) improved its ML models significantly; most teams no longer need custom fraud rules.
- Stripe Billing added usage-based pricing primitives natively — metered billing no longer requires a workaround.
- Link (Stripe's one-click checkout) now auto-fills for returning customers across all Stripe merchants.
Install
npm install stripe # server SDK
npm install @stripe/stripe-js @stripe/react-stripe-js # client SDK
Option 1: Stripe Checkout (recommended for most teams)
Create a checkout session server-side and redirect:
// app/api/checkout/route.ts (Next.js App Router)
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const { priceId } = await req.json()
const session = await stripe.checkout.sessions.create({
mode: 'payment', // or 'subscription'
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/pricing`,
automatic_tax: { enabled: true },
})
return Response.json({ url: session.url })
}
// Client
async function handleCheckout(priceId: string) {
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ priceId }),
headers: { 'Content-Type': 'application/json' },
})
const { url } = await res.json()
window.location.href = url
}
Option 2: Payment Element (custom UI)
// components/CheckoutForm.tsx
import { PaymentElement, useStripe, useElements } from '@stripe/react-stripe-js'
export function CheckoutForm() {
const stripe = useStripe()
const elements = useElements()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!stripe || !elements) return
const { error } = await stripe.confirmPayment({
elements,
confirmParams: { return_url: `${window.location.origin}/success` },
})
if (error) console.error(error.message)
}
return (
<form onSubmit={handleSubmit}>
<PaymentElement />
<button type="submit" disabled={!stripe}>Pay</button>
</form>
)
}
Wrap with <Elements stripe={stripePromise} options={{ clientSecret }}> where clientSecret comes from a server-side paymentIntents.create call.
Webhooks — the critical part
// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const body = await req.text()
const sig = req.headers.get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
} catch {
return new Response('Webhook signature invalid', { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed':
await fulfillOrder(event.data.object as Stripe.Checkout.Session)
break
case 'invoice.payment_failed':
await handleFailedPayment(event.data.object as Stripe.Invoice)
break
}
return new Response('ok')
}
Test webhooks locally with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
Comparison: Checkout vs Payment Element
| Feature |
Stripe Checkout |
Payment Element |
| Setup time |
~30 min |
~2 hours |
| Custom branding |
Limited |
Full control |
| Local payment methods |
Automatic |
Requires config |
| Tax calculation |
Built-in |
Manual |
| Redirect required |
Yes (hosted page) |
No |
| Best for |
Fast integration |
Branded checkout |
Subscriptions
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: monthlyPriceId, quantity: 1 }],
success_url: '...',
cancel_url: '...',
// Allow customers to manage subscription later:
customer_creation: 'always',
})
Handle customer.subscription.updated and customer.subscription.deleted webhooks to keep your database in sync.
How to pick the integration path
| Your situation |
Recommendation |
| Getting to market fast |
Stripe Checkout |
| Need branded checkout experience |
Payment Element |
| Subscriptions |
Checkout in subscription mode |
| Usage-based billing |
Stripe Billing + metered prices |
| Marketplace (split payments) |
Stripe Connect |
Common mistakes
Fulfilling orders on redirect — the success_url redirect is client-side and can be spoofed. Always verify via the checkout.session.completed webhook.
Not setting idempotency keys on retried requests — pass { idempotencyKey: orderId } as the request option on charge operations.
Storing card details yourself — use Stripe's vault; storing raw card data creates PCI scope you do not want.
Skipping the Stripe CLI for local webhook testing — without it, you're guessing whether your webhook handler works.
What to skip
- Building a custom card form from scratch — Payment Element handles 3DS, accessibility, and localization automatically.
- Polling the API to check payment status — use webhooks; polling creates race conditions and unnecessary API calls.
- Stripe.js CDN script without
loadStripe() — always use the official @stripe/stripe-js package which handles PCI compliance loading.
FAQ
Do I need a backend to use Stripe?
Yes — never put your Stripe secret key in the browser. Even a minimal API route or edge function is required to create payment intents and handle webhooks.
How do I test payments without a real card?
Use Stripe's test card numbers: 4242 4242 4242 4242 (success), 4000 0000 0000 9995 (decline), 4000 0025 0000 3155 (3DS required). Any future expiry date and any CVC work.
How do I handle refunds?
Via the Stripe dashboard or stripe.refunds.create({ payment_intent: id }) in your API. Refunds can be full or partial.
What currency should I use?
Use the smallest currency unit (cents for USD, pence for GBP). Stripe does not accept fractional cents; amount: 1999 means $19.99.
Where to go next