string;
variants: IVariant[];
pricing: { currency: string; amountCents: number }[];
inventorySnapshot: number;
lastUpdated: Date;
}
const variantSchema = new Schema({
optionKey: { type: String, required: true },
optionValue: { type: String, required: true },
skuSuffix: { type: String, required: true },
priceAdjustmentCents: { type: Number, default: 0 }
});
const catalogItemSchema = new Schema<ICatalogItem>({
sku: { type: String, required: true, unique: true, index: true },
title: { type: String, required: true },
variants: [variantSchema],
pricing: [{
currency: { type: String, required: true },
amountCents: { type: Number, required: true }
}],
inventorySnapshot: { type: Number, required: true, min: 0 },
lastUpdated: { type: Date, default: Date.now }
}, { timestamps: true });
// Compound index for efficient variant lookups
catalogItemSchema.index({ 'variants.skuSuffix': 1, sku: 1 });
export const CatalogItem = mongoose.model<ICatalogItem>('CatalogItem', catalogItemSchema);
### 2. Backend Orchestration (Node.js)
The Node.js backend acts as the orchestration layer. It manages product data, handles cart persistence, and interfaces with Stripe. A critical component is the `PaymentOrchestrator`, which ensures that payment processing and inventory deduction are handled safely.
**Architecture Decisions:**
- Use a service-oriented structure within the Node app to separate concerns (e.g., `CatalogService`, `PaymentService`).
- Implement Stripe Checkout Sessions for PCI compliance.
- Use MongoDB transactions when updating inventory and creating order records simultaneously.
**Code Example: Payment Orchestrator**
```typescript
import Stripe from 'stripe';
import mongoose from 'mongoose';
import { CatalogItem } from './models';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2023-10-16' });
export class PaymentOrchestrator {
async createCheckoutSession(cartItems: { sku: string; quantity: number }[], customerEmail: string) {
const session = await mongoose.startSession();
session.startTransaction();
try {
// 1. Validate inventory and lock stock
const lineItems = [];
for (const item of cartItems) {
const product = await CatalogItem.findOne({ sku: item.sku }).session(session);
if (!product || product.inventorySnapshot < item.quantity) {
throw new Error(`Insufficient inventory for ${item.sku}`);
}
// Atomic decrement
await CatalogItem.updateOne(
{ sku: item.sku },
{ $inc: { inventorySnapshot: -item.quantity } },
{ session }
);
lineItems.push({
price_data: {
currency: 'usd',
product_data: { name: product.title },
unit_amount: product.pricing[0].amountCents
},
quantity: item.quantity
});
}
// 2. Create Stripe Session with idempotency key
const stripeSession = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
success_url: `${process.env.FRONTEND_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/cart`,
customer_email: customerEmail,
metadata: { cartHash: this.generateCartHash(cartItems) }
}, { idempotencyKey: this.generateIdempotencyKey(cartItems, customerEmail) });
await session.commitTransaction();
return { sessionId: stripeSession.id, url: stripeSession.url };
} catch (error) {
await session.abortTransaction();
throw error;
} finally {
session.endSession();
}
}
private generateIdempotencyKey(items: any[], email: string): string {
return `checkout_${email}_${items.map(i => i.sku).sort().join('_')}`;
}
}
3. Frontend Integration (Next.js)
Next.js serves as the storefront. Product pages should use Static Site Generation (SSG) or ISR for performance, while the cart and checkout flow require client-side state management.
Implementation Strategy:
- Use
generateStaticParams to pre-render product pages.
- Implement revalidation tags to update product pages when inventory or prices change.
- Handle Stripe redirects securely via API routes.
Code Example: Product Page with ISR
import { CatalogItem } from '@/lib/db/models';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const items = await CatalogItem.find({}, { sku: 1 }).lean();
return items.map((item) => ({ sku: item.sku }));
}
export default async function ProductPage({ params }: { params: { sku: string } }) {
const product = await CatalogItem.findOne({ sku: params.sku }).lean();
if (!product) {
notFound();
}
return (
<div className="product-container">
<h1>{product.title}</h1>
<p>Price: ${(product.pricing[0].amountCents / 100).toFixed(2)}</p>
<p>Stock: {product.inventorySnapshot}</p>
{/* Add to Cart Button Component */}
</div>
);
}
export const revalidate = 60; // ISR revalidation interval
4. Webhook Handling
Stripe webhooks notify your backend of payment events. This handler must verify signatures, process events idempotently, and update order status.
Code Example: Webhook Handler
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { headers } from 'next/headers';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = headers().get('stripe-signature')!;
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
} catch (err) {
return NextResponse.json({ error: 'Webhook signature verification failed' }, { status: 400 });
}
// Process event asynchronously to respond quickly to Stripe
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
// Trigger order fulfillment logic, email notifications, etc.
await processOrderFulfillment(session);
}
return NextResponse.json({ received: true });
}
async function processOrderFulfillment(session: Stripe.Checkout.Session) {
// Implement idempotent order creation based on session metadata
console.log(`Processing order for session ${session.id}`);
}
Pitfall Guide
Production ecommerce systems fail due to edge cases. The following pitfalls are common in this stack and their remedies.
| Pitfall | Explanation | Fix |
|---|
| Inventory Race Conditions | Concurrent requests can oversell items if inventory checks and updates are not atomic. | Use MongoDB transactions or atomic $inc operations with validation checks within the same query. |
| Webhook Replay Attacks | Attackers may replay valid webhook payloads to trigger duplicate actions. | Verify Stripe signatures using stripe.webhooks.constructEvent. Implement idempotency keys for all processing logic. |
| Floating Point Currency Errors | Using floats for currency calculations leads to precision loss (e.g., $0.10 + $0.20 ≠ $0.30). | Store and calculate all monetary values in integer cents. Stripe API expects amounts in cents. |
| Blocking the Event Loop | Heavy post-payment tasks (e.g., generating PDFs, syncing ERP) can block Node.js, causing timeouts. | Offload heavy tasks to a message queue (e.g., BullMQ) or use async workers. Respond to webhooks immediately. |
| Inconsistent State on Failure | If payment succeeds but inventory update fails, data becomes inconsistent. | Wrap inventory deduction and order creation in a database transaction. Use Stripe idempotency keys to retry safely. |
| Client-Side Secret Exposure | Accidentally exposing Stripe secret keys in client-side code allows unauthorized API access. | Never include secret keys in Next.js client components. Use environment variables strictly in server-side code or API routes. |
| Cart Persistence Loss | Users lose cart data if sessions expire or if the cart is stored only in memory. | Persist cart data in MongoDB or Redis. Associate carts with user accounts or persistent session tokens. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Simple Catalog (<50 items) | Next.js + Stripe Checkout | Minimal backend complexity; fast deployment. | Low |
| High Volume / Complex Logic | Node.js Microservices + MongoDB | Scalability; granular control over inventory and payments. | Medium |
| Subscription Models | Node.js + Stripe Billing API | Native support for recurring payments and dunning management. | Medium |
| Multi-Vendor Marketplace | MongoDB + Role-Based Access | Flexible schema supports diverse vendor data structures. | High |
Configuration Template
Use this docker-compose.yml for local development to spin up the full stack.
version: '3.8'
services:
mongo:
image: mongo:6.0
ports:
- "27017:27017"
volumes:
- mongo-data:/data/db
environment:
MONGO_INITDB_ROOT_USERNAME: admin
MONGO_INITDB_ROOT_PASSWORD: password
api:
build: ./backend
ports:
- "3001:3001"
environment:
- MONGO_URI=mongodb://admin:password@mongo:27017/ecommerce?authSource=admin
- STRIPE_SECRET_KEY=sk_test_...
- STRIPE_WEBHOOK_SECRET=whsec_...
depends_on:
- mongo
storefront:
build: ./frontend
ports:
- "3000:3000"
environment:
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
- API_URL=http://api:3001
depends_on:
- api
volumes:
mongo-data:
Quick Start Guide
- Initialize Project: Create directories for
frontend (Next.js) and backend (Node.js). Set up the docker-compose.yml file as shown above.
- Configure Environment: Create
.env files in both frontend and backend directories. Add your Stripe keys and MongoDB connection strings.
- Start Infrastructure: Run
docker-compose up -d mongo to start the database. Verify connectivity.
- Install Dependencies: Run
npm install in both frontend and backend directories. Install required packages (mongoose, stripe, next, express).
- Run Development: Execute
npm run dev in the backend and npm run dev in the frontend. Access the storefront at http://localhost:3000 and verify API endpoints at http://localhost:3001.
- Test Webhooks: Use the Stripe CLI (
stripe listen) to forward webhook events to your local backend for testing payment flows.