Build a shopping cart in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app with Drizzle ORM that handles product listings, guest cart persistence via session cookies, quantity updates with stock validation, and guest-to-authenticated cart merging on login. No Stripe — that is covered by the checkout-flow guide. Deploy on Autoscale.
What you're building
A shopping cart is the foundation of any e-commerce app — it bridges product discovery and payment. The key technical challenges are not the product listings themselves, but the cart's persistence across sessions and the moment a guest visitor logs in. Without careful handling, a logged-in user's cart is empty even though they added items before signing up.
Replit Agent generates the full Express backend from a single prompt. The cart uses a UUID stored in an HTTP-only cookie as a session_id. Guest carts are identified by this session_id. When a user logs in via Replit Auth, the merge endpoint moves all guest cart items to the authenticated user's cart — summing quantities for any duplicate products. The price snapshot pattern stores unit_price at the time of adding rather than reading it from the products table, so your cart total stays consistent if prices change between adding and checkout.
This guide covers the cart experience only — product browsing through cart management. Payment processing is covered in the checkout-flow guide. Deploy on Autoscale: e-commerce browsing is bursty and the cart API is lightweight enough that cold starts are acceptable for a browse-and-add experience.
Final result
A fully functional shopping cart API with product listings, guest cart persistence, stock-validated quantity updates, price snapshotting, and seamless guest-to-authenticated cart merging — deployed on Replit Autoscale.
Tech stack
Prerequisites
- A Replit account (free tier is sufficient for this build)
- Basic understanding of what cookies and sessions are (no coding experience needed)
- A list of products with names, prices, and stock quantities to seed into the database
Build steps
Scaffold the project with Replit Agent
Create a new Repl and use the Agent prompt below to generate the full shopping cart schema and routes. The four tables and all Express routes will be generated in one step.
1// Type this into Replit Agent:2// Build a shopping cart system with Express and PostgreSQL using Drizzle ORM.3// Install express-session for cookie-based session management.4// Tables:5// - products: id serial pk, name text not null, description text,6// price integer not null (in cents), compare_at_price integer (original price for sale display),7// image_url text, category text, stock_quantity integer not null default 0,8// is_active boolean default true, created_at timestamp default now()9// - product_categories: id serial, name text not null unique,10// slug text not null unique, description text, position integer default 011// - carts: id serial pk, session_id text unique (guest carts),12// user_id text (authenticated user carts), created_at timestamp default now(),13// updated_at timestamp default now()14// - cart_items: id serial, cart_id integer FK carts not null,15// product_id integer FK products not null, quantity integer not null default 1,16// unit_price integer not null (snapshot price at time of adding),17// created_at timestamp default now()18// UNIQUE constraint on (cart_id, product_id)19// Routes:20// GET /api/products — list with optional category, search, min_price, max_price query params21// GET /api/products/:id — detail22// POST /api/cart/add — add item, create cart if needed, snapshot unit_price23// GET /api/cart — list items with product details joined24// PATCH /api/cart/items/:id — update quantity (validate against stock_quantity)25// DELETE /api/cart/items/:id — remove item26// POST /api/cart/merge — merge guest cart into authenticated user cart on login27// GET /api/cart/count — total item count for header badge28// Use Replit Auth. Set session_id cookie as HTTP-only UUID on first visit. Bind to 0.0.0.0.Pro tip: Ask Agent to create a seed script with 15-20 products across 3-4 categories so you have realistic data to test filters, search, and cart operations right away.
Expected result: A running Express app with all four tables created. The console shows the server started. Requesting GET /api/products returns the seeded products array.
Implement session cookie and cart creation
Every visitor gets a UUID session_id stored in an HTTP-only cookie on their first request. This identifies their guest cart. Authenticated users are identified by their Replit Auth user ID instead.
1const { v4: uuidv4 } = require('uuid');2const { carts } = require('../../shared/schema');3const { eq, or } = require('drizzle-orm');45// Middleware: ensure every request has a session_id cookie6async function ensureSession(req, res, next) {7 if (!req.cookies.session_id) {8 const sessionId = uuidv4();9 res.cookie('session_id', sessionId, {10 httpOnly: true,11 secure: true, // Replit deployments are always HTTPS12 maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days13 sameSite: 'lax',14 });15 req.sessionId = sessionId;16 } else {17 req.sessionId = req.cookies.session_id;18 }19 next();20}2122// Helper: get or create a cart for the current visitor23async function getOrCreateCart(sessionId, userId) {24 // Prefer authenticated user cart if logged in25 if (userId) {26 const [existing] = await db.select().from(carts).where(eq(carts.userId, userId));27 if (existing) return existing;28 const [created] = await db.insert(carts).values({ userId }).returning();29 return created;30 }3132 // Guest cart by session_id33 const [existing] = await db.select().from(carts).where(eq(carts.sessionId, sessionId));34 if (existing) return existing;35 const [created] = await db.insert(carts).values({ sessionId }).returning();36 return created;37}3839module.exports = { ensureSession, getOrCreateCart };Pro tip: Install the uuid package via the Replit packages panel or Shell (npm install uuid). The HTTP-only flag prevents JavaScript from reading the session cookie, protecting it from XSS attacks.
Expected result: Every first-time visitor gets a session_id cookie in their browser. Subsequent requests include the cookie automatically, allowing cart state to persist across page refreshes.
Build the cart add and update routes with price snapshotting
When adding an item, the route reads the current price from the products table and stores it as unit_price on the cart_items row. This ensures cart totals are stable even if the product price changes before checkout.
1const { products, cartItems, carts } = require('../../shared/schema');2const { eq, and, sql } = require('drizzle-orm');3const { getOrCreateCart } = require('../middleware/session');45// POST /api/cart/add — add item to cart6router.post('/cart/add', async (req, res) => {7 const { productId, quantity = 1 } = req.body;8 const userId = req.user?.id || null;9 const sessionId = req.sessionId;1011 // Fetch product and validate stock12 const [product] = await db.select().from(products)13 .where(and(eq(products.id, parseInt(productId)), eq(products.isActive, true)));1415 if (!product) return res.status(404).json({ error: 'Product not found' });16 if (product.stockQuantity < quantity) {17 return res.status(400).json({ error: `Only ${product.stockQuantity} in stock` });18 }1920 const cart = await getOrCreateCart(sessionId, userId);2122 // Check if item already in cart23 const [existing] = await db.select().from(cartItems)24 .where(and(eq(cartItems.cartId, cart.id), eq(cartItems.productId, product.id)));2526 if (existing) {27 const newQty = existing.quantity + quantity;28 if (newQty > product.stockQuantity) {29 return res.status(400).json({ error: `Cannot add more than ${product.stockQuantity} total` });30 }31 const [updated] = await db.update(cartItems)32 .set({ quantity: newQty })33 .where(eq(cartItems.id, existing.id))34 .returning();35 return res.json(updated);36 }3738 // Snapshot current price at time of adding39 const [item] = await db.insert(cartItems).values({40 cartId: cart.id,41 productId: product.id,42 quantity,43 unitPrice: product.price, // price snapshot44 }).returning();4546 res.status(201).json(item);47});4849// PATCH /api/cart/items/:id — update quantity50router.patch('/cart/items/:id', async (req, res) => {51 const { quantity } = req.body;52 if (!quantity || quantity < 1) {53 return res.status(400).json({ error: 'Quantity must be at least 1' });54 }5556 const [item] = await db.select().from(cartItems)57 .where(eq(cartItems.id, parseInt(req.params.id)));58 if (!item) return res.status(404).json({ error: 'Cart item not found' });5960 const [product] = await db.select().from(products).where(eq(products.id, item.productId));61 if (quantity > product.stockQuantity) {62 return res.status(400).json({ error: `Only ${product.stockQuantity} in stock` });63 }6465 const [updated] = await db.update(cartItems)66 .set({ quantity })67 .where(eq(cartItems.id, item.id))68 .returning();6970 res.json(updated);71});Pro tip: The price snapshot in unit_price means if you run a sale and reduce product prices, existing cart items keep their original price. This is intentional — it prevents the awkward situation where a cart total changes between adding items and checking out.
Expected result: POST /api/cart/add returns the cart item with unit_price equal to the current product price. PATCH /api/cart/items/:id rejects quantities greater than stock_quantity with a clear error message.
Build the guest-to-authenticated cart merge
When a guest logs in via Replit Auth, call POST /api/cart/merge. This transfers all guest cart items to the authenticated user's cart, summing quantities for duplicate products and respecting stock limits, then deletes the guest cart.
1const { carts, cartItems, products } = require('../../shared/schema');2const { eq, and } = require('drizzle-orm');34// POST /api/cart/merge — called immediately after Replit Auth login5router.post('/cart/merge', async (req, res) => {6 const userId = req.user?.id;7 if (!userId) return res.status(401).json({ error: 'Login required' });89 const sessionId = req.sessionId;1011 // Find the guest cart12 const [guestCart] = await db.select().from(carts).where(eq(carts.sessionId, sessionId));13 if (!guestCart) return res.json({ merged: 0, message: 'No guest cart to merge' });1415 // Get or create the authenticated user's cart16 let [userCart] = await db.select().from(carts).where(eq(carts.userId, userId));17 if (!userCart) {18 [userCart] = await db.insert(carts).values({ userId }).returning();19 }2021 // Get all items from both carts22 const guestItems = await db.select().from(cartItems).where(eq(cartItems.cartId, guestCart.id));23 const userItems = await db.select().from(cartItems).where(eq(cartItems.cartId, userCart.id));2425 let mergedCount = 0;2627 for (const guestItem of guestItems) {28 const [product] = await db.select().from(products).where(eq(products.id, guestItem.productId));29 const existing = userItems.find(i => i.productId === guestItem.productId);3031 if (existing) {32 // Sum quantities up to stock limit33 const mergedQty = Math.min(existing.quantity + guestItem.quantity, product.stockQuantity);34 await db.update(cartItems)35 .set({ quantity: mergedQty })36 .where(eq(cartItems.id, existing.id));37 } else {38 // Move item to user cart with stock-capped quantity39 const cappedQty = Math.min(guestItem.quantity, product.stockQuantity);40 await db.insert(cartItems).values({41 cartId: userCart.id,42 productId: guestItem.productId,43 quantity: cappedQty,44 unitPrice: guestItem.unitPrice, // preserve original price snapshot45 });46 }47 mergedCount++;48 }4950 // Delete guest cart and all its items51 await db.delete(cartItems).where(eq(cartItems.cartId, guestCart.id));52 await db.delete(carts).where(eq(carts.id, guestCart.id));5354 res.json({ merged: mergedCount, cartId: userCart.id });55});Pro tip: Call POST /api/cart/merge from your frontend immediately after the Replit Auth login callback fires. If you delay this call, the user might add items to their authenticated cart before the merge runs, creating duplicate line items.
Expected result: After login, POST /api/cart/merge returns { merged: N, cartId: X }. The authenticated user's cart now contains all items from the guest session. The guest cart is deleted. Duplicate products have their quantities summed up to stock limit.
Complete code
1const express = require('express');2const { carts, cartItems, products } = require('../../shared/schema');3const { eq, and, sql } = require('drizzle-orm');4const { db } = require('../db');5const { getOrCreateCart } = require('../middleware/session');67const router = express.Router();89// GET /api/cart — cart with joined product details10router.get('/cart', async (req, res) => {11 const cart = await getOrCreateCart(req.sessionId, req.user?.id || null);12 const items = await db.select({13 id: cartItems.id,14 quantity: cartItems.quantity,15 unitPrice: cartItems.unitPrice,16 productId: products.id,17 productName: products.name,18 productImageUrl: products.imageUrl,19 stockQuantity: products.stockQuantity,20 })21 .from(cartItems)22 .innerJoin(products, eq(cartItems.productId, products.id))23 .where(eq(cartItems.cartId, cart.id));2425 const total = items.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);26 res.json({ items, total, itemCount: items.reduce((sum, i) => sum + i.quantity, 0) });27});2829// GET /api/cart/count — lightweight count for header badge30router.get('/cart/count', async (req, res) => {31 const cart = await getOrCreateCart(req.sessionId, req.user?.id || null);32 const [result] = await db.select({ count: sql`SUM(quantity)` })33 .from(cartItems).where(eq(cartItems.cartId, cart.id));34 res.json({ count: parseInt(result.count) || 0 });35});3637// DELETE /api/cart/items/:id38router.delete('/cart/items/:id', async (req, res) => {39 await db.delete(cartItems).where(eq(cartItems.id, parseInt(req.params.id)));40 res.json({ deleted: true });41});4243module.exports = router;Customization ideas
Saved for later / wishlist
Add a saved_items table identical in structure to cart_items but with a separate purpose. Add POST /api/cart/items/:id/save-for-later and POST /api/saved/:id/move-to-cart routes. Display saved items below the cart with a 'Move to Cart' button.
Cart abandonment detection
Add a last_activity_at timestamp to carts. Update it on every cart modification. Run a background query (setInterval on Reserved VM) every hour to find carts with items that have not been active for 24 hours and send a reminder email via SendGrid.
Promo code discounts
Add a promo_codes table (code text unique, discount_type enum percent/fixed, discount_value integer, max_uses integer, used_count integer, expires_at timestamp) and a POST /api/cart/apply-promo route. Calculate and display the discount in the GET /api/cart response.
Low stock warnings
In the GET /api/cart route response, add a is_low_stock boolean to each item set to true when product.stock_quantity <= 5. Display a 'Only N left' warning badge on those items to create urgency.
Common pitfalls
Pitfall: Not snapshotting the product price at time of adding to cart
How to avoid: Store the current product.price as unit_price on the cart_items row at insert time. Use unit_price for all total calculations. This decouples cart pricing from live product pricing.
Pitfall: Not merging the guest cart on login
How to avoid: Call POST /api/cart/merge immediately after the Replit Auth login callback. Move all guest cart items to the authenticated cart, sum quantities for duplicates, cap at stock_quantity, then delete the guest cart.
Pitfall: Validating stock only at checkout instead of at cart add and update
How to avoid: Check product.stock_quantity in both POST /api/cart/add and PATCH /api/cart/items/:id. Return a 400 with a clear message like 'Only 5 in stock' before the item is added or updated.
Best practices
- Snapshot the product price as unit_price at cart item creation time — never read live prices from products table for cart total calculations.
- Call POST /api/cart/merge immediately after login — delaying the merge call risks creating duplicate cart state if the user adds items while logged in before the merge runs.
- Store the session_id cookie as HTTP-only and Secure — this prevents JavaScript from reading it and ensures it is only sent over HTTPS, which Replit deployment URLs always use.
- Always validate stock quantity in the cart routes, not just at checkout — returning a clear 'Only N in stock' message at add time is far better UX than failing silently at payment.
- Use Drizzle Studio (built into Replit) to inspect carts and cart_items tables during testing — you can manually verify that the merge correctly transfers and deduplicates items.
- Deploy on Autoscale — e-commerce browsing is bursty and the cart API is lightweight. Cold starts on Autoscale are acceptable for a browse-and-add experience where requests are not time-sensitive.
- Keep the GET /api/cart/count route as a separate lightweight endpoint that only returns an integer — the header badge should not trigger a full cart fetch with product joins on every page load.
AI prompts to try
Copy these prompts to build this project faster.
I'm building a shopping cart with Express, PostgreSQL, and Drizzle ORM on Replit. I have four tables: products, product_categories, carts (with either a session_id for guests or user_id for authenticated users), and cart_items (with unit_price as a price snapshot and a unique constraint on cart_id + product_id). Help me write a cart merge function in Node.js that takes a guest session cart and an authenticated user cart, moves all items from the guest cart to the user cart, sums quantities for duplicate products up to the stock_quantity limit, and then deletes the guest cart.
Add product search with PostgreSQL full-text search to the shopping cart. Create a GIN index on products using to_tsvector('english', name || ' ' || coalesce(description, '')). Update the GET /api/products route to accept a search query param and use to_tsquery to filter results ranked by relevance using ts_rank. Return a relevance_score field alongside each product. Show the search query and result count in the API response.
Frequently asked questions
Why does the cart use a cookie instead of localStorage?
HTTP-only cookies are not readable by JavaScript, which protects the session_id from XSS attacks. They are also sent automatically on every request by the browser, so the cart API always knows the visitor's identity without any frontend code passing a token. localStorage would require extra JavaScript on every API call and is vulnerable to script injection.
What happens to the guest cart after a user logs in?
Call POST /api/cart/merge immediately after the Replit Auth login callback fires. The merge route moves all items from the guest session cart to the authenticated user's cart. For products already in the user's cart, quantities are summed up to the stock limit. The guest cart is then deleted and the session cookie is updated to point to the authenticated cart.
Why store unit_price on the cart item instead of reading from the products table?
Storing a price snapshot ensures the cart total stays stable even if you run a sale or update product prices between when the customer adds an item and when they check out. Without snapshotting, a price drop could reduce the cart total unexpectedly, and a price increase could cause customer complaints.
Does this include Stripe payment processing?
No. This guide covers product browsing, cart management, guest persistence, and cart merging only. Payment processing — Stripe Checkout, order creation, and confirmation emails — is covered in the checkout-flow guide, which picks up where this one ends.
What Replit plan do I need?
The free tier is sufficient. This build uses Express, PostgreSQL (built into Replit), Replit Auth, and express-session — all available without a paid plan. Deploy on Autoscale, which is available on all plans.
Should I deploy on Autoscale or Reserved VM?
Autoscale. Shopping cart browsing and cart management requests are stateless — each request reads from PostgreSQL and returns a response. Cold starts on Autoscale are acceptable here. Use Reserved VM only when you add the checkout-flow layer with Stripe webhooks, which require an always-on server.
Can RapidDev help build a custom shopping cart?
Yes. RapidDev has built 600+ apps including e-commerce platforms with multi-currency support, wishlist features, promo codes, and cart abandonment recovery. Book a free consultation at rapidevelopers.com.
How do I prevent overselling when stock is low?
Stock is validated in both POST /api/cart/add and PATCH /api/cart/items/:id. If the requested quantity exceeds product.stock_quantity, the route returns a 400 error with a message like 'Only 3 in stock'. For high-traffic flash sales, add a FOR UPDATE lock in the stock check query to prevent concurrent requests from each seeing the same available stock.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation