Switch Language
Toggle Theme

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

3 AM. I’m staring at error logs on my screen, checking the Stripe Webhook configuration for the 27th time. Users are complaining that money has been charged, but order status still shows “pending payment”. I’m completely lost — everything worked perfectly in the test environment, why did it break in production?

Honestly, when I first started my Next.js e-commerce project, I thought the hardest part would be building the UI and styling. But once I got my hands dirty, I realized shopping cart state management, payment integration, and order workflows — every single step was a minefield. Redux felt too heavy to learn, Context API had performance issues, Stripe docs were all in English and overwhelming, and Webhooks? I had no clue what those were.

What really drove me crazy was that online tutorials either only covered shopping carts OR payments, rarely explaining the complete flow end-to-end. You might be wondering: “How do I choose a state management library?” “What exactly are Webhooks for?” “How do order status and payment status sync up?”

In this article, I want to fill all these gaps for you. We’ll use Zustand for shopping cart state management (lightweight and easy), Stripe for payment integration (mainstream international solution), and Webhooks for order processing (the only reliable approach). Every step includes complete code that you can copy and paste.

Ever experienced that “finally got it working!” feeling of accomplishment? By following this article, you will.

Why Choose Zustand for Shopping Cart State Management?

State Management Selection in 2025: Stop Overthinking

Honestly, choosing a state management library can be really frustrating. Redux docs are thick as a dictionary, Context API performance issues are everywhere you search, and Zustand feels too new to trust. I was bouncing between these three options until I saw some data that helped me decide.

Starting in 2021, Zustand became the fastest-growing React state management library by Stars. By 2025, its design philosophy has been proven: functional, embraces hooks, elegant API. More importantly, its learning curve is genuinely gentle, unlike Redux where you need to understand a ton of concepts (actions, reducers, dispatch, middleware…).

So how do you choose? Here’s my breakdown:

  • Small projects (<10 pages): Context API is enough, don’t overthink it
  • Medium projects (10-50 pages): Zustand, lightweight and sufficient
  • Large projects (50+ pages, multi-team collaboration): Redux Toolkit, mature tooling

Shopping carts are actually a perfect fit for Zustand. Why? Because they need cross-component sharing (product lists, cart icons, checkout pages all need access), persistence (can’t lose data on page refresh), and good performance (only update relevant components, no global re-renders). Zustand handles all of this, and with half the code of Redux.

Zustand Shopping Cart Code in Action

Enough theory, let’s dive into code. First, install dependencies:

npm install zustand

Then create the cart Store (/store/cartStore.js):

import { create } from 'zustand'
import { persist } from 'zustand/middleware'

export const useCartStore = create(
  persist(
    (set, get) => ({
      // State
      items: [], // [{ id, name, price, quantity, image }]

      // Computed properties
      get total() {
        return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0)
      },
      get count() {
        return get().items.reduce((sum, item) => sum + item.quantity, 0)
      },

      // Methods
      addItem: (product) => set((state) => {
        const existing = state.items.find(item => item.id === product.id)
        if (existing) {
          // Already exists, increment quantity
          return {
            items: state.items.map(item =>
              item.id === product.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            )
          }
        } else {
          // New item, add to cart
          return { items: [...state.items, { ...product, quantity: 1 }] }
        }
      }),

      removeItem: (productId) => set((state) => ({
        items: state.items.filter(item => item.id !== productId)
      })),

      updateQuantity: (productId, quantity) => set((state) => ({
        items: state.items.map(item =>
          item.id === productId ? { ...item, quantity } : item
        )
      })),

      clearCart: () => set({ items: [] })
    }),
    {
      name: 'shopping-cart', // localStorage key
    }
  )
)

This code looks long, but the logic is actually simple. The items array stores products, total and count are computed values (total price and quantity), and a few methods handle add/remove/update operations. The persist middleware automatically saves data to localStorage, so page refreshes don’t lose anything.

Using it in components is super simple too:

import { useCartStore } from '@/store/cartStore'

function ProductCard({ product }) {
  const addItem = useCartStore(state => state.addItem)

  return (
    <button onClick={() => addItem(product)}>
      Add to Cart
    </button>
  )
}

function CartIcon() {
  const count = useCartStore(state => state.count)

  return <div>Cart ({count})</div>
}

Notice how useCartStore(state => state.addItem) is a selector. It only subscribes to the addItem method and won’t re-render when other cart data changes. This is Zustand’s performance secret — precise subscriptions.

By the way, if you’ve used Redux’s useSelector and useDispatch, you’ll find Zustand is so much simpler. No action types, no reducer functions, just write methods directly in the Store.

Some might ask: “If my project already uses Redux, should I switch to Zustand?” No need. Redux Toolkit is also great — the C-Shopping open-source e-commerce project uses Redux Toolkit + RTK Query and still tracks data flow and ensures stability just fine. But for new projects, I personally recommend Zustand — lower learning curve, faster development.

Complete Stripe Payment Integration Flow

Understanding the Stripe Payment Flow First

The first time I read Stripe docs, my head was full of questions: What’s a Checkout Session? What’s a Payment Intent? Why redirect to Stripe’s page? Can’t I complete payments on my own website?

Looking back now, the flow is actually pretty clear:

  1. Frontend: User clicks “Checkout”, calls your API to create Checkout Session
  2. Backend: Creates Session, returns a session.id
  3. Frontend: Takes session.id, uses Stripe.js to redirect to Stripe’s hosted payment page
  4. User: Fills credit card info on Stripe’s page, completes payment
  5. Stripe: After successful payment, sends Webhook notification to your backend
  6. Backend: Receives Webhook, creates order, deducts inventory, sends email
  7. Stripe: Redirects user back to your website (success_url)

Here’s the key: Never process payment success logic on the frontend. Why? Because users might close their browser after payment without clicking “complete”, or network might disconnect, or they might deliberately not redirect to try and get stuff for free. The only reliable method is Webhooks — we’ll cover this in detail later.

Creating Stripe Checkout Session

First install dependencies:

npm install stripe @stripe/stripe-js

Then configure environment variables (.env.local):

STRIPE_SECRET_KEY=sk_test_xxxxx  # Backend use, never expose to frontend
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx  # Frontend use
STRIPE_WEBHOOK_SECRET=whsec_xxxxx  # Webhook signature verification

Create API route (/pages/api/create-checkout.js):

import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  try {
    const { items } = req.body // Shopping cart data

    // Create line_items (Stripe's required format)
    const lineItems = items.map(item => ({
      price_data: {
        currency: 'usd',
        product_data: {
          name: item.name,
          images: [item.image],
        },
        unit_amount: Math.round(item.price * 100), // Stripe uses cents
      },
      quantity: item.quantity,
    }))

    // Create Checkout Session
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: lineItems,
      mode: 'payment', // One-time payment (use 'subscription' for subscriptions)
      success_url: `${req.headers.origin}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${req.headers.origin}/cart`,
      metadata: {
        // Store custom data, accessible in Webhooks
        userId: req.user?.id || 'guest',
      },
    })

    res.status(200).json({ sessionId: session.id })
  } catch (err) {
    console.error('Failed to create Checkout Session:', err)
    res.status(500).json({ error: err.message })
  }
}

A few important details:

  • unit_amount must be multiplied by 100 because Stripe uses cents as the unit ($99.99 = 9999 cents)
  • {CHECKOUT_SESSION_ID} in success_url is a placeholder that Stripe automatically replaces with the real session_id
  • metadata can store your own data (like user ID, order notes), accessible in Webhooks

Frontend Checkout Call

On the checkout page (/pages/checkout.js):

import { loadStripe } from '@stripe/stripe-js'
import { useCartStore } from '@/store/cartStore'

const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)

export default function CheckoutPage() {
  const { items, total } = useCartStore()

  const handleCheckout = async () => {
    try {
      // Call backend API to create Session
      const response = await fetch('/api/create-checkout', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ items }),
      })

      const { sessionId } = await response.json()

      // Redirect to Stripe payment page
      const stripe = await stripePromise
      const { error } = await stripe.redirectToCheckout({ sessionId })

      if (error) {
        console.error('Failed to redirect to payment page:', error)
        alert(error.message)
      }
    } catch (err) {
      console.error('Payment initiation failed:', err)
      alert('Payment failed, please try again')
    }
  }

  return (
    <div>
      <h1>Checkout</h1>
      {items.map(item => (
        <div key={item.id}>
          {item.name} x {item.quantity} = ${item.price * item.quantity}
        </div>
      ))}
      <div>Total: ${total}</div>
      <button onClick={handleCheckout}>Proceed to Payment</button>
    </div>
  )
}

After clicking “Proceed to Payment”, users will be redirected to Stripe’s hosted payment page. Stripe handles this page for you — no need to write forms, handle credit card validation, or fraud detection. Saves a ton of work.

You might ask: “Can I customize the payment page styling?” Yes. Stripe supports custom colors, logos, and fonts, but the overall layout is fixed. If you want complete control over the UI, you can use Stripe Elements (embed payment form on your frontend), but that’s much more complex — not recommended for beginners.

Post-Payment Redirect

After users complete payment, Stripe redirects to your configured success_url. You can display order details on this page:

// /pages/success.js
import { useEffect, useState } from 'react'
import { useRouter } from 'next/router'

export default function SuccessPage() {
  const router = useRouter()
  const { session_id } = router.query
  const [order, setOrder] = useState(null)

  useEffect(() => {
    if (session_id) {
      // Fetch order info from backend
      fetch(`/api/order?session_id=${session_id}`)
        .then(res => res.json())
        .then(data => setOrder(data))
    }
  }, [session_id])

  if (!order) return <div>Loading...</div>

  return (
    <div>
      <h1>Payment Successful!</h1>
      <p>Order ID: {order.id}</p>
      <p>Amount: ${order.total}</p>
    </div>
  )
}

But remember, this page is only for display — real order creation must happen in the Webhook. Let’s talk about how to set up Webhooks.

Webhook Order Processing and Status Sync

Why Are Webhooks So Important?

When I first built payment functionality, I naively thought that users redirecting to the success page meant payment succeeded, so I put all the order logic there. During testing, I discovered that users could close their browser right after payment, leaving orders uncreated and money in limbo. Panic mode.

I later learned from Stripe’s official docs: Webhooks are the only reliable way to process orders. Why?

  • User redirects are unreliable: Browser closures, network disconnects, not clicking complete button — all kinds of scenarios can happen
  • Security requirements: Order creation, inventory deduction, shipping — these sensitive operations must happen on the backend, not controlled by the frontend
  • Stripe official recommendation: All critical business logic should be in Webhooks

What are Webhooks? Simply put, Stripe’s servers proactively call your servers to tell you “Hey, a payment succeeded” or “A subscription was cancelled”. You receive the notification and handle it accordingly.

Creating the Webhook Endpoint

Create /pages/api/stripe-webhook.js in Next.js:

import Stripe from 'stripe'
import { buffer } from 'micro'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET

// Critical config: Disable Next.js default body parsing
export const config = {
  api: {
    bodyParser: false,
  },
}

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).send('Method not allowed')
  }

  const buf = await buffer(req)
  const sig = req.headers['stripe-signature']

  let event

  try {
    // Verify Webhook signature (super important!)
    event = stripe.webhooks.constructEvent(buf, sig, webhookSecret)
  } catch (err) {
    console.error('Webhook signature verification failed:', err.message)
    return res.status(400).send(`Webhook Error: ${err.message}`)
  }

  // Handle different event types
  switch (event.type) {
    case 'checkout.session.completed':
      await handleCheckoutSessionCompleted(event.data.object)
      break
    case 'payment_intent.succeeded':
      await handlePaymentIntentSucceeded(event.data.object)
      break
    case 'invoice.payment_failed':
      await handleInvoicePaymentFailed(event.data.object)
      break
    default:
      console.log(`Unhandled event type: ${event.type}`)
  }

  res.status(200).json({ received: true })
}

async function handleCheckoutSessionCompleted(session) {
  console.log('Payment successful!', session.id)

  // Get cart data (from metadata or query by session.id)
  const userId = session.metadata.userId
  const sessionId = session.id
  const total = session.amount_total / 100 // Convert back to dollars

  // Check if order already exists (idempotency guarantee)
  const existingOrder = await db.order.findUnique({
    where: { stripeSessionId: sessionId }
  })

  if (existingOrder) {
    console.log('Order already exists, skipping creation')
    return
  }

  // Create order
  const order = await db.order.create({
    data: {
      userId,
      stripeSessionId: sessionId,
      status: 'paid',
      total,
      // ... other fields
    }
  })

  // Deduct inventory
  await updateInventory(order.items)

  // Send confirmation email
  await sendOrderConfirmationEmail(userId, order)

  console.log('Order created successfully:', order.id)
}

async function handlePaymentIntentSucceeded(paymentIntent) {
  // Confirm payment received
  console.log('Payment confirmed:', paymentIntent.id)
}

async function handleInvoicePaymentFailed(invoice) {
  // Handle subscription payment failure
  console.log('Payment failed:', invoice.id)
  // Send reminder email, suspend service, etc.
}

Key points:

  1. Must disable bodyParser: Stripe needs the raw request body to verify signatures. If Next.js parses the body beforehand, signature verification will fail
  2. Must verify signature: stripe.webhooks.constructEvent() verifies the request actually came from Stripe, preventing malicious third-party forgery
  3. Idempotency handling: Stripe may send duplicate Webhooks (network issues, retry mechanisms). Your code must handle repeated calls. Using stripeSessionId as a unique index ensures one payment doesn’t create multiple orders

Testing Webhooks Locally

Stripe can’t directly call your localhost, so local development requires the Stripe CLI. First install the CLI:

# Mac
brew install stripe/stripe-cli/stripe

# Windows (using Scoop)
scoop install stripe

# Or download from official site
# https://stripe.com/docs/stripe-cli

Login and listen for Webhooks:

stripe login
stripe listen --forward-to localhost:3000/api/stripe-webhook

The CLI will give you a temporary webhook secret (like whsec_xxxxx), copy it to .env.local:

STRIPE_WEBHOOK_SECRET=whsec_xxxxx

Then trigger test events in another terminal:

stripe trigger checkout.session.completed

You’ll see logs in both the CLI and Next.js console, confirming the Webhook was received. Now you can debug order creation logic.

I got stuck here for ages, constantly getting signature verification failures. Turned out I hadn’t disabled Next.js’s bodyParser, causing the body to be parsed early. Make sure to add that export const config!

Order Status Management

Order status flow looks roughly like this:

Pending → Paid → Preparing → Shipped → Completed

        Cancelled/Refunded

Store status as an enum in the database:

// schema.prisma
model Order {
  id               String   @id @default(cuid())
  stripeSessionId  String   @unique  // Idempotency guarantee
  userId           String
  status           OrderStatus @default(PENDING)
  total            Float
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

enum OrderStatus {
  PENDING      // Pending payment
  PAID         // Paid
  PREPARING    // Preparing for shipment
  SHIPPED      // Shipped
  COMPLETED    // Completed
  CANCELLED    // Cancelled
  REFUNDED     // Refunded
}

When the Webhook receives checkout.session.completed, set status to PAID. Subsequent shipping and completion statuses are updated manually or automatically by your admin system.

Error Handling

Webhooks may fail (database disconnects, third-party services down, etc.). Stripe has retry mechanisms, but you should log failures:

async function handleCheckoutSessionCompleted(session) {
  try {
    // Business logic
  } catch (error) {
    console.error('Order processing failed:', error)
    // Log to monitoring system (Sentry, LogRocket, etc.)
    await logError({
      type: 'webhook_error',
      event: 'checkout.session.completed',
      sessionId: session.id,
      error: error.message,
    })
    throw error // Throw error so Stripe knows it failed and will retry
  }
}

What if a Webhook fails? Stripe will automatically retry for 3 days. During that time, you can view failed Webhooks in the Stripe Dashboard and manually resend them.

Complete Order Flow in Action

Now that we have all the pieces ready, let’s put them together and see how a complete order flow works.

Complete User Order Path

  1. Product page: User clicks “Add to Cart”, Zustand Store updates, cart icon count +1
  2. Cart page: User views cart, adjusts quantities, clicks “Checkout”
  3. Checkout page: Shows order summary, clicks “Proceed to Payment”
  4. Frontend: Calls /api/create-checkout, passes cart data
  5. Backend: Creates Stripe Session, returns sessionId
  6. Frontend: Redirects to Stripe hosted payment page
  7. User: Fills credit card info, clicks “Pay”
  8. Stripe: Processes payment, sends Webhook to /api/stripe-webhook on success
  9. Backend Webhook: Verifies signature → Creates order → Deducts inventory → Sends email
  10. Stripe: Redirects user back to /success?session_id=xxx
  11. Frontend: Success page calls /api/order?session_id=xxx, displays order details

The whole flow looks complex, but each step is actually very clear. The key is that step 9 must happen in the Webhook, not relying on step 11.

Database Design Considerations

model Order {
  id               String      @id @default(cuid())
  stripeSessionId  String      @unique  // Idempotency
  userId           String
  status           OrderStatus @default(PENDING)
  total            Float
  items            OrderItem[]
  createdAt        DateTime    @default(now())
  updatedAt        DateTime    @updatedAt

  user User @relation(fields: [userId], references: [id])
}

model OrderItem {
  id        String @id @default(cuid())
  orderId   String
  productId String
  quantity  Int
  price     Float  // Price at time of order, prevents future price changes from affecting orders

  order   Order   @relation(fields: [orderId], references: [id])
  product Product @relation(fields: [productId], references: [id])
}

Notice the price field in OrderItem stores the price at order time, not the current price from the associated Product. This way, even if products increase in price later, historical orders still use the original price.

Edge Case Handling

1. What if inventory is insufficient?

Check before creating Checkout Session:

// /pages/api/create-checkout.js
const { items } = req.body

// Check inventory
for (const item of items) {
  const product = await db.product.findUnique({ where: { id: item.id } })
  if (product.stock < item.quantity) {
    return res.status(400).json({ error: `${product.name} out of stock` })
  }
}

// Sufficient inventory, continue creating Session...

2. What if payment succeeds but Webhook fails?

Stripe will automatically retry for 3 days. You can also manually resend Webhooks in the Stripe Dashboard. Or write a scheduled task to periodically check for “payment succeeded but order not created” Sessions and backfill orders.

3. What if user pays but shipping times out?

Check inventory again before shipping. If out of stock, contact user for refund or replacement.

Production Environment Deployment Considerations

Test environment works fine — but don’t deploy straight to production. There are some gotchas to watch out for.

Environment Variable Configuration

Production keys are different from test environment — don’t mix them up:

# .env.production
STRIPE_SECRET_KEY=sk_live_xxxxx  # Note it's live not test
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx  # Production Webhook Secret

When deploying on Vercel or other platforms, remember to configure environment variables. Never commit Secret Keys to Git.

Webhook Endpoint Configuration

Test environment uses Stripe CLI forwarding, production requires manual configuration in Stripe Dashboard:

  1. Login to Stripe Dashboard
  2. Go to “Developers” → “Webhooks”
  3. Click “Add endpoint”
  4. Enter your production URL: https://yourdomain.com/api/stripe-webhook
  5. Select events to listen for: checkout.session.completed, payment_intent.succeeded, etc.
  6. After saving, copy the Signing secret (like whsec_xxxxx) and configure in environment variables

I forgot to configure this on my first deployment. After going live, orders weren’t being created. Took forever to realize the Webhook wasn’t being received at all.

Security Checklist

Go through this checklist before deployment:

  • ✅ All payment logic completed on backend (frontend only redirects)
  • ✅ Webhook verifies signature (stripe.webhooks.constructEvent)
  • ✅ Check payment amount matches order amount (prevent frontend price tampering)
  • ✅ Implement idempotency (stripeSessionId unique index)
  • ✅ Log all payment-related events (Sentry, Datadog, etc.)
  • ✅ Set up exception alerts (Webhook failure rate, payment success rate)

Point three is especially important. Although pricing is set on the backend when creating the Session, you should verify again in the Webhook to prevent someone from bypassing the frontend to directly call the Stripe API (low probability, but security matters).

Monitoring and Alerts

Production environments should integrate monitoring systems:

// /pages/api/stripe-webhook.js
import * as Sentry from '@sentry/nextjs'

export default async function handler(req, res) {
  try {
    // ... Webhook logic
  } catch (error) {
    Sentry.captureException(error, {
      tags: {
        type: 'stripe_webhook',
        event: event.type,
      },
    })
    throw error
  }
}

Recommended monitoring metrics:

  • Webhook failure rate (alert if >5%)
  • Payment success rate (sudden drops might mean Stripe is down or configuration errors)
  • Order creation duration (investigate if >3 seconds)

Summary: Complete Path from Zero to Production

After all that, let’s recap the key steps:

  1. Shopping cart state management: Choose Zustand (lightweight) or Redux Toolkit (large projects), use persist middleware for persistence
  2. Payment integration: Create Stripe Checkout Session, redirect to hosted payment page, skip form validation hassles
  3. Order processing: Create orders, deduct inventory, send emails in Webhooks for reliability
  4. Production deployment: Configure environment variables, set up Webhook endpoint, integrate monitoring and alerts

Remember these three core principles:

  • Payment logic must be on backend: Frontend is untrusted
  • Webhooks are the only reliable source: User redirects are unreliable
  • Security is always priority one: Verify signatures, prevent replay attacks, log everything

If this is your first e-commerce payment implementation, I recommend running through the complete flow in Stripe’s test environment first. You can use test card number 4242 4242 4242 4242 (any expiry and CVV). Once test environment is solid, switch to production.

Finally, some recommended advanced resources:

  • Stripe Official Docs: https://stripe.com/docs (English, but very detailed)
  • Next.js + Stripe Complete Tutorial: Pedro Alonso’s 2025 guide (search “Stripe Next.js 15 complete guide”)
  • Open Source Reference: C-Shopping e-commerce platform, uses Redux Toolkit + Stripe

Hope this article helps you avoid some pitfalls. When you first see orders automatically created successfully, inventory correctly deducted, and users receiving confirmation emails — that sense of accomplishment is truly unmatched. Keep at it!

Complete Implementation Flow for Next.js E-commerce Cart and Stripe Payment

Detailed steps to build an e-commerce shopping cart and payment system from scratch, including state management, payment integration, and complete order processing flow

⏱️ Estimated time: 2 hr

  1. 1

    Step1: Install Dependencies and Configure Zustand Shopping Cart

    Install Zustand state management library:
    • npm install zustand

    Create cart Store (/store/cartStore.js):
    • Define items array to store products
    • Add total and count computed properties
    • Implement addItem, removeItem, updateQuantity, clearCart methods
    • Use persist middleware to save to localStorage

    Key configurations:
    • persist middleware automatically persists data, no loss on refresh
    • Use selectors for subscription (useCartStore(state => state.addItem)) to avoid unnecessary re-renders

    Use cases: Small to medium projects (10-50 pages), need lightweight state management
  2. 2

    Step2: Create Stripe Checkout Session API

    Install Stripe dependencies:
    • npm install stripe @stripe/stripe-js

    Configure environment variables (.env.local):
    • STRIPE_SECRET_KEY=sk_test_xxxxx (backend use, don't expose)
    • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx (frontend use)
    • STRIPE_WEBHOOK_SECRET=whsec_xxxxx (Webhook signature verification)

    Create API route (/pages/api/create-checkout.js):
    • Accept cart items data
    • Convert to Stripe line_items format (note unit_amount must multiply by 100)
    • Create checkout.sessions (set success_url and cancel_url)
    • Store custom data in metadata (userId, etc.)
    • Return sessionId to frontend

    Key details:
    • Stripe uses cents as unit, multiply price by 100
    • Use placeholder {CHECKOUT_SESSION_ID} in success_url
    • metadata can store business data, accessible in Webhooks
  3. 3

    Step3: Frontend Stripe Checkout Call

    Implement checkout page (/pages/checkout.js):
    • Use loadStripe to load Stripe.js
    • Call /api/create-checkout to create Session
    • Use stripe.redirectToCheckout() to redirect to payment page

    Error handling:
    • catch network errors
    • Check error returned by stripe.redirectToCheckout
    • Show user-friendly error messages

    Payment page explanation:
    • Stripe hosted payment page, no need to write your own form
    • Automatically handles credit card validation, fraud detection
    • Customizable colors, logos, fonts

    Important notes:
    • Never process payment success logic on frontend
    • Users may close browser or not click complete button
    • Real order creation must happen in Webhooks
  4. 4

    Step4: Configure Webhook Endpoint for Order Processing

    Create Webhook API (/pages/api/stripe-webhook.js):

    Must configure:
    • export const config = { api: { bodyParser: false } } (disable body parsing)
    • Use buffer(req) to get raw request body
    • stripe.webhooks.constructEvent() to verify signature

    Handle event types:
    • checkout.session.completed: Payment successful, create order
    • payment_intent.succeeded: Confirm payment received
    • invoice.payment_failed: Subscription payment failed

    Idempotency guarantee:
    • Check if stripeSessionId already exists
    • Add unique index in database
    • Prevent duplicate Webhook calls from creating multiple orders

    Business logic:
    • Create order record (status: 'paid')
    • Deduct inventory (updateInventory)
    • Send confirmation email (sendOrderConfirmationEmail)
    • Log events and errors

    Local testing:
    • stripe login (login CLI)
    • stripe listen --forward-to localhost:3000/api/stripe-webhook
    • stripe trigger checkout.session.completed (test events)

    Critical notes:
    • Must disable bodyParser, otherwise signature verification fails
    • Must verify signature to prevent malicious forgery
    • Stripe automatically retries failed Webhooks for 3 days
  5. 5

    Step5: Production Deployment and Security Configuration

    Environment variable configuration:
    • Use production keys (sk_live_xxxxx and pk_live_xxxxx)
    • Configure environment variables on platform (Vercel/Netlify)
    • Never commit Secret Keys to Git

    Stripe Dashboard configuration:
    • Go to Developers → Webhooks
    • Add production endpoint (https://yourdomain.com/api/stripe-webhook)
    • Select events to listen for (checkout.session.completed, etc.)
    • Copy Signing secret and configure in environment variables

    Security checklist:
    • ✅ All payment logic on backend
    • ✅ Webhook verifies signature
    • ✅ Verify payment amount matches order amount
    • ✅ Implement idempotency (stripeSessionId unique index)
    • ✅ Log all payment events
    • ✅ Set up monitoring alerts (Webhook failure rate >5% triggers alert)

    Monitoring metrics:
    • Webhook failure rate
    • Payment success rate
    • Order creation duration (investigate if >3s)

    Integrate monitoring system:
    • Use Sentry/LogRocket to log errors
    • Configure alert rules
    • Regularly check Stripe Dashboard Webhook logs

    Testing process:
    • Use test card 4242 4242 4242 4242
    • Verify complete flow (cart → payment → Webhook → order creation)
    • Test failure scenarios (insufficient inventory, Webhook failures, etc.)

FAQ

How do I choose between Redux and Zustand? Which is right for my project?
Choice mainly depends on project scale and team situation:

• Small projects (<10 pages): Context API is sufficient, no need for extra state management libraries
• Medium projects (10-50 pages): Zustand is better — lightweight, low learning curve, less code
• Large projects (50+ pages, multi-team collaboration): Redux Toolkit — mature tooling, strong debugging, established community

Specific scenarios:
• New project, rapid iteration: Choose Zustand, quick to learn, fast development
• Existing Redux project: No need to switch, Redux Toolkit works great
• Team unfamiliar with state management: Zustand has gentler learning curve, easier onboarding

Shopping cart scenario recommendation: Zustand excels at cross-component sharing, persistent storage, and performance optimization.
Why can't I process payment success logic on the frontend?
Processing payment success logic on the frontend has fatal issues:

Unreliability:
• Users might close browser right after payment
• Network disconnects can cause redirect failures
• Users might deliberately not click complete to try getting free stuff

Security risks:
• Frontend code can be tampered with and bypassed
• Sensitive operations like order creation and inventory deduction can't be exposed on frontend
• Can't prevent malicious users from forging payment success status

Correct approach:
• All critical business logic happens in Webhooks
• Stripe servers directly notify your backend (doesn't go through user's browser)
• Webhooks have signature verification — secure and reliable
• Stripe official recommendation: Webhooks are the only reliable source for order processing

Frontend success page is only for display, doesn't handle any business logic.
Webhook signature verification keeps failing — what should I do?
Common causes of Webhook signature verification failure and solutions:

Most common reason (90%):
• Next.js bodyParser not disabled
• Solution: Add export const config = { api: { bodyParser: false } } to API route

Other reasons:
• Webhook Secret configured incorrectly (check STRIPE_WEBHOOK_SECRET in .env.local)
• Using wrong environment key (test and production secrets are different)
• Request body modified by middleware (check for global middleware processing body)

Debugging steps:
1. Confirm bodyParser: false is configured
2. Print req.headers['stripe-signature'] to check if it exists
3. Test with Stripe CLI: stripe listen --forward-to localhost:3000/api/stripe-webhook
4. Check CLI output for detailed error messages
5. Confirm using the temporary webhook secret provided by CLI

Local testing notes:
• Local development requires Stripe CLI forwarding
• CLI provides temporary webhook secret (whsec_xxxxx)
• Each CLI restart generates new secret, must update .env.local
How do I prevent duplicate Webhook calls from creating multiple orders?
Key to preventing duplicate orders is implementing idempotency:

Database level:
• Add unique index to stripeSessionId field
• Prisma example: stripeSessionId String @unique
• Database automatically rejects duplicate inserts

Code level:
• Query if order exists before creating
• Use findUnique({ where: { stripeSessionId } })
• If exists, return directly without creating new order

Example code:
```javascript
const existingOrder = await db.order.findUnique({
where: { stripeSessionId: sessionId }
})

if (existingOrder) {
console.log('Order already exists, skipping creation')
return
}

// Only create new order if doesn't exist
const order = await db.order.create({ ... })
```

Why idempotency is needed:
• Stripe may send duplicate Webhooks (network issues, retry mechanisms)
• Your code must safely handle repeated calls
• Avoid same payment creating multiple orders, duplicate inventory deduction

Other recommendations:
• Log every Webhook call
• Monitor duplicate call frequency
• Set up alert mechanisms
Orders not being created after production deployment — how do I troubleshoot?
Troubleshoot in this order:

1. Check if Webhook is being received:
• Login to Stripe Dashboard → Developers → Webhooks
• Check Webhook call history and status (success/failure)
• If no call records, endpoint configuration is the issue

2. Check endpoint configuration:
• Is URL correct (https://yourdomain.com/api/stripe-webhook)
• Did you select checkout.session.completed event type
• Is endpoint status enabled

3. Check environment variables:
• Is STRIPE_WEBHOOK_SECRET configured correctly
• Are you using production environment secret (not test environment)
• Confirm environment variables are set on deployment platform (Vercel/Netlify)

4. Check Webhook endpoint code:
• Is bodyParser disabled
• Is signature verification correct
• Are there error log outputs

5. View application logs:
• Check server logs (Vercel Logs/CloudWatch, etc.)
• Look for error stack traces
• Confirm if Webhook handler function is being executed

6. Manual testing:
• Find failed Webhook in Stripe Dashboard
• Click "Resend" to manually resend
• Observe success/failure and error messages

Common errors:
• Forgot to add Webhook endpoint in production
• Used test environment webhook secret
• Deployment platform firewall blocking Stripe requests

Verify after fixing:
• Test complete payment flow with test card
• Confirm order creation, inventory deduction, email sending all work normally

13 min read · Published on: Jan 7, 2026 · Modified on: Jan 15, 2026

Comments

Sign in with GitHub to leave a comment

Related Posts