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:
- Frontend: User clicks “Checkout”, calls your API to create Checkout Session
- Backend: Creates Session, returns a session.id
- Frontend: Takes session.id, uses Stripe.js to redirect to Stripe’s hosted payment page
- User: Fills credit card info on Stripe’s page, completes payment
- Stripe: After successful payment, sends Webhook notification to your backend
- Backend: Receives Webhook, creates order, deducts inventory, sends email
- 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_amountmust be multiplied by 100 because Stripe uses cents as the unit ($99.99 = 9999 cents){CHECKOUT_SESSION_ID}insuccess_urlis a placeholder that Stripe automatically replaces with the real session_idmetadatacan 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:
- Must disable bodyParser: Stripe needs the raw request body to verify signatures. If Next.js parses the body beforehand, signature verification will fail
- Must verify signature:
stripe.webhooks.constructEvent()verifies the request actually came from Stripe, preventing malicious third-party forgery - Idempotency handling: Stripe may send duplicate Webhooks (network issues, retry mechanisms). Your code must handle repeated calls. Using
stripeSessionIdas 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
- Product page: User clicks “Add to Cart”, Zustand Store updates, cart icon count +1
- Cart page: User views cart, adjusts quantities, clicks “Checkout”
- Checkout page: Shows order summary, clicks “Proceed to Payment”
- Frontend: Calls
/api/create-checkout, passes cart data - Backend: Creates Stripe Session, returns sessionId
- Frontend: Redirects to Stripe hosted payment page
- User: Fills credit card info, clicks “Pay”
- Stripe: Processes payment, sends Webhook to
/api/stripe-webhookon success - Backend Webhook: Verifies signature → Creates order → Deducts inventory → Sends email
- Stripe: Redirects user back to
/success?session_id=xxx - 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:
- Login to Stripe Dashboard
- Go to “Developers” → “Webhooks”
- Click “Add endpoint”
- Enter your production URL:
https://yourdomain.com/api/stripe-webhook - Select events to listen for:
checkout.session.completed,payment_intent.succeeded, etc. - 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 (
stripeSessionIdunique 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:
- Shopping cart state management: Choose Zustand (lightweight) or Redux Toolkit (large projects), use persist middleware for persistence
- Payment integration: Create Stripe Checkout Session, redirect to hosted payment page, skip form validation hassles
- Order processing: Create orders, deduct inventory, send emails in Webhooks for reliability
- 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
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
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
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
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
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?
• 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?
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?
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?
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?
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
Related Posts
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Admin Panel in Practice: Complete Guide to RBAC Permission System Design and Implementation
Next.js Admin Panel in Practice: Complete Guide to RBAC Permission System Design and Implementation
Next.js Real-Time Chat: The Right Way to Use WebSocket and SSE

Comments
Sign in with GitHub to leave a comment