Skip to main content
RapidDev - Software Development Agency

How to Build a Shopping Cart with Replit

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'll build

  • Product listings API with category filter, keyword search, and price range query params
  • Guest cart persistence using a UUID stored in an HTTP-only session cookie
  • Cart item management with add, update quantity, and remove routes
  • Stock validation on quantity updates — prevents adding more items than available
  • Price snapshot on cart item insert — cart totals do not change if product prices are updated later
  • Guest-to-authenticated cart merge on login that sums duplicate quantities up to stock limit
  • Cart item count endpoint for the header badge that avoids fetching full cart data
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read1-2 hoursReplit FreeApril 2026RapidDev Engineering Team
TL;DR

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

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth
express-sessionSession Management

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

1

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.

prompt.txt
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 0
11// - 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 params
21// GET /api/products/:id — detail
22// POST /api/cart/add — add item, create cart if needed, snapshot unit_price
23// GET /api/cart — list items with product details joined
24// PATCH /api/cart/items/:id — update quantity (validate against stock_quantity)
25// DELETE /api/cart/items/:id — remove item
26// POST /api/cart/merge — merge guest cart into authenticated user cart on login
27// GET /api/cart/count — total item count for header badge
28// 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.

2

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.

server/middleware/session.js
1const { v4: uuidv4 } = require('uuid');
2const { carts } = require('../../shared/schema');
3const { eq, or } = require('drizzle-orm');
4
5// Middleware: ensure every request has a session_id cookie
6async 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 HTTPS
12 maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
13 sameSite: 'lax',
14 });
15 req.sessionId = sessionId;
16 } else {
17 req.sessionId = req.cookies.session_id;
18 }
19 next();
20}
21
22// Helper: get or create a cart for the current visitor
23async function getOrCreateCart(sessionId, userId) {
24 // Prefer authenticated user cart if logged in
25 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 }
31
32 // Guest cart by session_id
33 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}
38
39module.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.

3

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.

server/routes/cart.js
1const { products, cartItems, carts } = require('../../shared/schema');
2const { eq, and, sql } = require('drizzle-orm');
3const { getOrCreateCart } = require('../middleware/session');
4
5// POST /api/cart/add — add item to cart
6router.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;
10
11 // Fetch product and validate stock
12 const [product] = await db.select().from(products)
13 .where(and(eq(products.id, parseInt(productId)), eq(products.isActive, true)));
14
15 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 }
19
20 const cart = await getOrCreateCart(sessionId, userId);
21
22 // Check if item already in cart
23 const [existing] = await db.select().from(cartItems)
24 .where(and(eq(cartItems.cartId, cart.id), eq(cartItems.productId, product.id)));
25
26 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 }
37
38 // Snapshot current price at time of adding
39 const [item] = await db.insert(cartItems).values({
40 cartId: cart.id,
41 productId: product.id,
42 quantity,
43 unitPrice: product.price, // price snapshot
44 }).returning();
45
46 res.status(201).json(item);
47});
48
49// PATCH /api/cart/items/:id — update quantity
50router.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 }
55
56 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' });
59
60 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 }
64
65 const [updated] = await db.update(cartItems)
66 .set({ quantity })
67 .where(eq(cartItems.id, item.id))
68 .returning();
69
70 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.

4

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.

server/routes/cart.js
1const { carts, cartItems, products } = require('../../shared/schema');
2const { eq, and } = require('drizzle-orm');
3
4// POST /api/cart/merge — called immediately after Replit Auth login
5router.post('/cart/merge', async (req, res) => {
6 const userId = req.user?.id;
7 if (!userId) return res.status(401).json({ error: 'Login required' });
8
9 const sessionId = req.sessionId;
10
11 // Find the guest cart
12 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' });
14
15 // Get or create the authenticated user's cart
16 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 }
20
21 // Get all items from both carts
22 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));
24
25 let mergedCount = 0;
26
27 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);
30
31 if (existing) {
32 // Sum quantities up to stock limit
33 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 quantity
39 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 snapshot
45 });
46 }
47 mergedCount++;
48 }
49
50 // Delete guest cart and all its items
51 await db.delete(cartItems).where(eq(cartItems.cartId, guestCart.id));
52 await db.delete(carts).where(eq(carts.id, guestCart.id));
53
54 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

server/routes/cart.js
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');
6
7const router = express.Router();
8
9// GET /api/cart — cart with joined product details
10router.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));
24
25 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});
28
29// GET /api/cart/count — lightweight count for header badge
30router.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});
36
37// DELETE /api/cart/items/:id
38router.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});
42
43module.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.

ChatGPT Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.