
Photo by Blake Wisz on Unsplash
Adding Stripe Checkout to Your Ecommerce Template: Patterns & Pitfalls
Adding Stripe Checkout to Your Ecommerce Template: Patterns & Pitfalls
Stripe is the default payment provider for nearly every modern ecommerce template on MVPHub. The integration looks simple on paper — drop in a checkout button, customer pays, webhook fires, order is created — but the production-ready version has more edge cases than most tutorials cover.
This guide walks through the patterns that actually hold up in production: which checkout flavor to pick, how to handle webhooks reliably, how to use Stripe metadata to keep your database in sync, and how to test it all without losing your mind.
TL;DR
- Stripe Checkout (hosted) is the right default for 95% of ecommerce stores — PCI compliance comes free, and the UI is battle-tested.
- Stripe Elements is the right choice when you need checkout on your own domain, custom fields, or a non-standard flow.
- Webhooks are non-optional. Treat the redirect back from Stripe as UI state, not business state.
- Use Stripe metadata to carry your order ID through the payment — it's how the webhook handler knows what got paid.
- Idempotency keys are the single biggest reliability win most teams miss.
- Test in Stripe's test mode with the official test cards before you switch to live keys.
Stripe Checkout vs Stripe Elements: pick the right one
Stripe offers two main checkout flavors and they solve different problems.
Stripe Checkout (hosted)
What it is: A fully hosted checkout page on Stripe's domain. You create a Checkout Session via the API, redirect the customer to Stripe, and they handle the UI, validation, 3DS, and confirmation. On success, the customer is redirected back to your success_url.
Why 95% of stores should use it:
- PCI compliance is Stripe's problem — you never touch card data, so your store is out of scope for PCI SAQ A-EP or similar
- 3D Secure / SCA is handled automatically (important in EU/UK for SCA compliance)
- Mobile UX is excellent — Apple Pay, Google Pay, and Link work without extra code
- Localization is free — Stripe auto-detects language and currency
- Changes are pushed by Stripe — new payment methods and features land without you updating code
Why you might not want it:
- Customers briefly leave your domain during payment (some brands resist this for perceived trust reasons)
- You have fewer UI customization options
- You can't collect custom fields during checkout (limited to Stripe's supported fields)
Stripe Elements (embedded)
What it is: React components (<PaymentElement>, <CardElement>) you mount in your own checkout page. You create a Payment Intent, render the element, confirm payment client-side, and handle the result.
When to use it:
- You absolutely need checkout on your own domain (B2B stores where enterprise buyers expect it)
- You have custom fields or multi-step checkout that Hosted Checkout can't express
- You're building a subscription flow with unusual pricing or proration logic
- You need to combine Stripe with other checkout concerns (gift cards, loyalty points, custom taxes)
What it costs you:
- PCI scope goes up. Even though Stripe Elements handles the sensitive data, you're now responsible for the surrounding UI and can fail PCI audits for non-Stripe reasons (like a sloppy Content Security Policy)
- You write more code and have more to test
- 3DS and SCA still work, but you have to handle the confirmation step yourself
Recommendation: Start with Stripe Checkout. Move to Elements only when you can point at a concrete limitation that blocks your business.
Basic Stripe Checkout integration
The core flow for an MVPHub Next.js template (or any Next.js store) looks like this:
1. Create the Checkout Session on the server
// app/api/checkout/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: Request) {
const { cartId, items } = await request.json()
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: items.map((item: CartLineItem) => ({
price_data: {
currency: 'usd',
product_data: {
name: item.name,
images: item.imageUrl ? [item.imageUrl] : undefined,
},
unit_amount: item.priceCents,
},
quantity: item.quantity,
})),
// This is the key part — metadata flows through to the webhook
metadata: {
cartId,
source: 'mvphub-template',
},
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/cart`,
automatic_tax: { enabled: true }, // If you're using Stripe Tax
shipping_address_collection: {
allowed_countries: ['US', 'CA', 'GB', 'AU', 'NZ'],
},
})
return NextResponse.json({ url: session.url })
}
Key details:
metadata.cartIdis the thread that connects the Stripe payment to your internal cart/order. Without it, the webhook can't find the right row in your database.success_urluses the{CHECKOUT_SESSION_ID}template so you can look up the session on the success page.automatic_tax: { enabled: true }requires Stripe Tax to be configured in your dashboard. If you're not using Stripe Tax yet, remove this.
2. Redirect the user to Stripe
// On the client, from your Checkout button
async function handleCheckout() {
const res = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ cartId, items: cart.items }),
})
const { url } = await res.json()
window.location.href = url
}
That's the entire client-side code. No Stripe.js library required for hosted checkout.
3. Handle the webhook
This is the part most tutorials get wrong. The redirect back to your site is not the source of truth. It can be skipped, replayed, or spoofed. The webhook is the only reliable signal.
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server'
import Stripe from 'stripe'
import { headers } from 'next/headers'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!
export async function POST(request: Request) {
const body = await request.text()
const signature = (await headers()).get('stripe-signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error('Webhook signature verification failed', err)
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
// Handle the event
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
const cartId = session.metadata?.cartId
if (!cartId) {
console.error('Webhook missing cartId metadata')
return NextResponse.json({ received: true })
}
// Idempotent order creation
await createOrderFromCart({
cartId,
stripeSessionId: session.id,
paymentIntentId: session.payment_intent as string,
amountPaid: session.amount_total!,
currency: session.currency!,
customerEmail: session.customer_details?.email,
})
break
}
case 'charge.refunded': {
// Handle refunds
break
}
// Other event types as needed
}
return NextResponse.json({ received: true })
}
Critical patterns:
- Verify the signature. If you skip
constructEvent, anyone who knows your webhook URL can create orders for free. This is not a hypothetical attack. - Parse the raw body, not the parsed JSON. Next.js App Router's
request.text()preserves bytes;request.json()does not. Signature verification requires exact bytes. - Use metadata to find your order. Never trust
customer_emailalone — metadata.cartId is the primary key. - Make order creation idempotent. Stripe retries failed webhooks. If the same webhook fires twice, you should create the order exactly once. Use the Stripe session ID or payment intent ID as a uniqueness key in your database.
- Return 200 fast. Stripe treats non-200 responses as failures and retries. If your order creation is slow, respond 200 immediately and process the order in a queue.
Using metadata properly
Stripe metadata is a simple key-value store attached to any Stripe object (Session, Payment Intent, Customer). Use it as the bridge between Stripe and your database.
Good metadata patterns:
metadata: {
cartId: 'cart_abc123', // Internal ID for finding your cart/order
source: 'web-checkout', // Where this payment originated
couponCode: 'SUMMER20', // If used a promo
affiliateId: 'aff_xyz', // For attribution
customerUserId: 'user_123', // If logged in
}
Bad metadata patterns:
metadata: {
// Don't put PII in metadata — it shows up in Stripe logs
email: 'customer@example.com',
// Don't store whole objects — metadata values are strings ≤500 chars
cart: JSON.stringify(fullCartObject),
// Don't use metadata as your primary database
lineItems: [...],
}
Metadata is meant to be a foreign key into your system, not the system itself.
Idempotency: the hidden reliability win
Stripe supports idempotency keys on every API call. If you pass the same key twice, Stripe returns the original result instead of creating a duplicate charge. This is how you safely retry failed requests.
const session = await stripe.checkout.sessions.create(
{ /* ... session params ... */ },
{ idempotencyKey: `cart_${cartId}_${cart.updatedAt}` }
)
The key format matters: it should change when the cart contents change (so a modified cart creates a new session) but stay the same across retries of the same request. Using cartId + cart.updatedAt is a common pattern.
Without idempotency keys, a slow network during checkout can create duplicate charges. With them, retrying is always safe.
Handling subscriptions
If your template sells subscriptions (grocery boxes, software licenses, memberships), Stripe Checkout supports them natively:
const session = await stripe.checkout.sessions.create({
mode: 'subscription', // <-- Key change
line_items: [{
price: 'price_1Abc123', // Recurring price ID from Stripe dashboard
quantity: 1,
}],
metadata: {
planId: 'weekly-basic',
customerUserId: userId,
},
success_url: `${baseUrl}/account?welcome=true`,
cancel_url: `${baseUrl}/pricing`,
})
For subscriptions, the webhook events you care about are:
checkout.session.completed— initial subscription createdcustomer.subscription.updated— plan changes, status changescustomer.subscription.deleted— canceledinvoice.payment_succeeded— recurring charge succeededinvoice.payment_failed— recurring charge failed (dunning)
Each one should update your internal subscription state. The MVPHub grocery subscription templates implement this pattern end-to-end, including dunning retry logic.
Testing without losing your mind
Stripe's test mode is the best thing about Stripe. Use it.
Test cards
Stripe publishes a list of test cards that trigger specific outcomes:
| Card number | What it does |
|---|---|
4242 4242 4242 4242 | Successful charge |
4000 0025 0000 3155 | Requires 3DS authentication |
4000 0000 0000 9995 | Insufficient funds |
4000 0000 0000 0002 | Generic decline |
4100 0000 0000 0019 | Fraudulent (blocked) |
Any future date and any 3-digit CVC work for test cards. Use these in every test — don't use a real card just because "it's easier."
Test webhooks locally
Stripe CLI forwards real webhook events to your local dev server:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This gives you a test webhook secret (whsec_...) to use in dev. When you trigger a test payment, the Stripe CLI forwards the webhook to your local handler. You can even replay historical events:
stripe events resend evt_1Abc123
This is dramatically better than trying to reproduce webhook flows from scratch.
End-to-end test flow
Before you flip to live keys, do this once manually:
- Load your storefront in incognito
- Add a product to cart
- Proceed to checkout
- Complete payment with a test card
- Verify you land on the success page
- Verify the order appears in Stripe dashboard (in test mode)
- Verify the order appears in your database with the right amount
- Verify the confirmation email goes out
- Trigger a refund from Stripe dashboard
- Verify the refund is reflected in your database via the
charge.refundedwebhook
If any step breaks, fix it before going live. This takes 20 minutes and saves weeks of chasing production bugs.
Common mistakes
Using request.json() in the webhook handler. Breaks signature verification. Always use request.text().
Trusting the redirect back from Stripe as proof of payment. The success_url is UI state. The webhook is truth.
Not handling checkout.session.async_payment_succeeded. Some payment methods (bank redirect, buy-now-pay-later) are async. The session completes without a payment, and a separate event fires when the payment actually settles. If you only handle checkout.session.completed, async orders never get created.
Storing Stripe customer IDs but not linking them to users. You'll want to query "what did user X buy" at some point. Add a stripeCustomerId column to your users table and populate it on first checkout.
Putting webhook handling behind auth. The webhook endpoint must be publicly reachable by Stripe's servers. Don't put it behind your authenticated API middleware.
Forgetting to rotate webhook secrets after team changes. Webhook secrets are long-lived credentials. Treat them like database passwords.
MVPHub templates with Stripe wired up
Every Next.js and Shopify headless template on MVPHub ships with Stripe already integrated. Specifically:
- Next.js storefronts — Stripe Checkout flow with webhook handler and order creation
- Next.js + Medusa starters — Medusa's Stripe plugin handles the integration
- Grocery subscription templates — Full subscription lifecycle with dunning retries
You can use the patterns in this guide directly on top of any of them, or extend what's already there.
Next steps
- Browse Next.js templates — Stripe integration included
- Deploying Next.js + Medusa to production — ops walkthrough
- Medusa v2 Starter Guide — backend setup including Stripe plugin
- Shopify Storefront API integration guide — Stripe's role in headless Shopify
- Stripe official testing docs — full test card reference
Stripe is the boring, reliable foundation underneath modern ecommerce. Set it up once with the patterns above and you won't have to think about it again until you add new features.







