Back
Adding Stripe Checkout to Your Ecommerce Template: Patterns & Pitfalls

Photo by Blake Wisz on Unsplash

Adding Stripe Checkout to Your Ecommerce Template: Patterns & Pitfalls

MM
MVPHub
11 min read

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.cartId is 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_url uses 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:

  1. Verify the signature. If you skip constructEvent, anyone who knows your webhook URL can create orders for free. This is not a hypothetical attack.
  2. 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.
  3. Use metadata to find your order. Never trust customer_email alone — metadata.cartId is the primary key.
  4. 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.
  5. 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 created
  • customer.subscription.updated — plan changes, status changes
  • customer.subscription.deleted — canceled
  • invoice.payment_succeeded — recurring charge succeeded
  • invoice.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 numberWhat it does
4242 4242 4242 4242Successful charge
4000 0025 0000 3155Requires 3DS authentication
4000 0000 0000 9995Insufficient funds
4000 0000 0000 0002Generic decline
4100 0000 0000 0019Fraudulent (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:

  1. Load your storefront in incognito
  2. Add a product to cart
  3. Proceed to checkout
  4. Complete payment with a test card
  5. Verify you land on the success page
  6. Verify the order appears in Stripe dashboard (in test mode)
  7. Verify the order appears in your database with the right amount
  8. Verify the confirmation email goes out
  9. Trigger a refund from Stripe dashboard
  10. Verify the refund is reflected in your database via the charge.refunded webhook

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:

You can use the patterns in this guide directly on top of any of them, or extend what's already there.


Next steps

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.

Working products with full source code — live demo, one-time purchase, instant delivery.

Browse the marketplace
GR LIVEecommerce

Groover Multi-Purpose Store

$149

A fully-polished, multi-purpose e-commerce template engineered for brands that need the full feature set on day one — not a minimal starter you outgrow in a month. Groover ships with a live Medusa-backed catalog, category + collection merchandising, search with multi-facet filtering (category, collection, price, sale, stock, sort), product-detail with variant selection + image gallery + stock messaging + related products, Stripe Elements checkout with provider-aware setup panels, account dashboard with guest order lookup and authenticated order history, customer auth with login/register/logout/profile edit, wishlist with guest browser persistence and signed-in customer sync, blog list + detail, store directory, track-order page, branded 404, About/Contact/FAQ/Terms legal shell, GTM-friendly dataLayer wired into PDP/cards/wishlist/cart/checkout/search, locale + RTL foundation with persistent language switcher, PWA installability baseline, theme switching that applies before hydration and persists in both local storage and cookies, header active-route navigation with live mini-cart summary, skip-link / focus accessibility basics, app-level and route-level loading fallbacks, a recoverable error boundary, generated robots.txt and sitemap.xml, shared SEO metadata helpers, and a Playwright / Vitest / Lighthouse test harness. Every copy string lives in a typed content map so rebranding is a find-and-replace pass, not a code rewrite. Deploy it as-is or use it as the most complete starting point you can buy for a serious storefront.

★★★★★0 soldAstro · Medusa
FU LIVEecommerce

Furniture Store

$49

An elegant furniture and home furnishing e-commerce app with a design-forward Next.js storefront for SEO-optimized product pages and server-rendered category browsing. Alternative framework and mobile ports are available on demand. The visual design emphasizes large product imagery, room-based browsing, and material/color variant selection. Built with Radix UI, shadcn/ui, Tailwind CSS, and Framer Motion for a premium feel. Connects to any headless commerce backend — Medusa JS SDK integration is included. Form handling via React Hook Form with Zod validation ensures robust checkout and account flows. Great for furniture brands, interior design shops, or home decor marketplaces.

★★★★★0 soldExpo · Next.js
PE LIVEecommerce

Perfume Store

$49

A luxury-styled perfume and fragrance e-commerce app built for premium brand presentation. The ready-to-buy Next.js storefront features rich product pages with scent profiles, bottle size variants, gift set options, and server-rendered collections. Mobile, backend, and alternative framework ports are available on demand. The design uses shadcn/ui and Tailwind CSS with an elegant, minimalist aesthetic suited for luxury goods. Easy to customize — swap product data, update branding, and deploy. Perfect for perfume brands, fragrance boutiques, or niche scent marketplaces.

★★★★★0 soldMedusa · Expo

Keep reading — popular eCommerce guides on MVPHub.

All eCommerce articles

Explore other MVP verticals

MVPHub publishes templates and guides for ecommerce, SaaS, marketplaces, AI apps, booking platforms, subscription stores, directory sites, and more. Here are fresh picks from other verticals.