How to build an e-commerce backend in Node.js
An e-commerce backend needs to handle products, carts, orders, and payments while keeping the data consistent even when multiple users are shopping simultaneously. As the creator of CoreUI with 25 years of backend development experience, I’ve built the API layers for several commercial e-commerce platforms and the most important architectural decision is keeping cart state on the server to prevent inventory inconsistencies. The core data model links products, carts, orders, and users, and the API surface exposes clean REST endpoints for each resource. This guide focuses on the critical cart-to-order transition — the most complex part of any e-commerce backend.
Define the Prisma schema for products, carts, and orders.
// prisma/schema.prisma
model Product {
id Int @id @default(autoincrement())
name String
price Decimal @db.Decimal(10, 2)
stock Int @default(0)
cartItems CartItem[]
orderItems OrderItem[]
}
model Cart {
id Int @id @default(autoincrement())
userId Int @unique
user User @relation(fields: [userId], references: [id])
items CartItem[]
updatedAt DateTime @updatedAt
}
model CartItem {
id Int @id @default(autoincrement())
cartId Int
productId Int
quantity Int
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id])
@@unique([cartId, productId])
}
model Order {
id Int @id @default(autoincrement())
userId Int
total Decimal @db.Decimal(10, 2)
status String @default("pending")
items OrderItem[]
createdAt DateTime @default(now())
}
model OrderItem {
id Int @id @default(autoincrement())
orderId Int
productId Int
quantity Int
price Decimal @db.Decimal(10, 2)
order Order @relation(fields: [orderId], references: [id])
product Product @relation(fields: [productId], references: [id])
}
@@unique([cartId, productId]) prevents duplicate product entries in the cart — adding the same product twice increases quantity instead. OrderItem.price stores the price at the time of purchase, preserving the order history even if the product price changes later.
Cart Service
Manage adding, updating, and clearing cart items.
// src/cart/cart.service.js
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export async function getOrCreateCart(userId) {
return prisma.cart.upsert({
where: { userId },
create: { userId, items: {} },
update: {},
include: { items: { include: { product: true } } }
})
}
export async function addToCart(userId, productId, quantity = 1) {
const cart = await getOrCreateCart(userId)
return prisma.cartItem.upsert({
where: { cartId_productId: { cartId: cart.id, productId } },
create: { cartId: cart.id, productId, quantity },
update: { quantity: { increment: quantity } }
})
}
export async function removeFromCart(userId, productId) {
const cart = await getOrCreateCart(userId)
return prisma.cartItem.delete({
where: { cartId_productId: { cartId: cart.id, productId } }
})
}
upsert creates the cart item if it doesn’t exist, or increments the quantity if it does. This pattern prevents race conditions when two requests try to add the same product simultaneously.
Checkout: Cart to Order
Convert the cart into an order with stock validation inside a transaction.
// src/orders/orders.service.js
export async function checkout(userId) {
return prisma.$transaction(async (tx) => {
const cart = await tx.cart.findUnique({
where: { userId },
include: { items: { include: { product: true } } }
})
if (!cart || cart.items.length === 0) {
throw new Error('Cart is empty')
}
// Validate stock for all items
for (const item of cart.items) {
if (item.product.stock < item.quantity) {
throw new Error(`Insufficient stock for ${item.product.name}`)
}
}
const total = cart.items.reduce(
(sum, item) => sum + Number(item.product.price) * item.quantity,
0
)
// Create order and decrement stock
const order = await tx.order.create({
data: {
userId,
total,
items: {
create: cart.items.map(item => ({
productId: item.productId,
quantity: item.quantity,
price: item.product.price
}))
}
}
})
// Decrement stock for each product
for (const item of cart.items) {
await tx.product.update({
where: { id: item.productId },
data: { stock: { decrement: item.quantity } }
})
}
// Clear the cart
await tx.cartItem.deleteMany({ where: { cartId: cart.id } })
return order
})
}
Running the checkout inside a prisma.$transaction ensures that if any step fails — stock check, order creation, or stock decrement — all changes are rolled back. This prevents orders being created without corresponding stock decrements, or stock being decremented without an order.
Best Practice Note
This is the transactional checkout pattern used in CoreUI e-commerce backend templates. For high-traffic stores, implement optimistic locking using a version field on the product to detect concurrent modifications. Once the order is created, trigger the payment flow — see how to integrate Stripe in Node.js to create a payment intent for the order total.



