# How to Build a Shopping Cart with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
// Type this into Replit Agent:
// Build a shopping cart system with Express and PostgreSQL using Drizzle ORM.
// Install express-session for cookie-based session management.
// Tables:
// - products: id serial pk, name text not null, description text,
//   price integer not null (in cents), compare_at_price integer (original price for sale display),
//   image_url text, category text, stock_quantity integer not null default 0,
//   is_active boolean default true, created_at timestamp default now()
// - product_categories: id serial, name text not null unique,
//   slug text not null unique, description text, position integer default 0
// - carts: id serial pk, session_id text unique (guest carts),
//   user_id text (authenticated user carts), created_at timestamp default now(),
//   updated_at timestamp default now()
// - cart_items: id serial, cart_id integer FK carts not null,
//   product_id integer FK products not null, quantity integer not null default 1,
//   unit_price integer not null (snapshot price at time of adding),
//   created_at timestamp default now()
//   UNIQUE constraint on (cart_id, product_id)
// Routes:
// GET /api/products — list with optional category, search, min_price, max_price query params
// GET /api/products/:id — detail
// POST /api/cart/add — add item, create cart if needed, snapshot unit_price
// GET /api/cart — list items with product details joined
// PATCH /api/cart/items/:id — update quantity (validate against stock_quantity)
// DELETE /api/cart/items/:id — remove item
// POST /api/cart/merge — merge guest cart into authenticated user cart on login
// GET /api/cart/count — total item count for header badge
// 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.

```
const { v4: uuidv4 } = require('uuid');
const { carts } = require('../../shared/schema');
const { eq, or } = require('drizzle-orm');

// Middleware: ensure every request has a session_id cookie
async function ensureSession(req, res, next) {
  if (!req.cookies.session_id) {
    const sessionId = uuidv4();
    res.cookie('session_id', sessionId, {
      httpOnly: true,
      secure: true,       // Replit deployments are always HTTPS
      maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
      sameSite: 'lax',
    });
    req.sessionId = sessionId;
  } else {
    req.sessionId = req.cookies.session_id;
  }
  next();
}

// Helper: get or create a cart for the current visitor
async function getOrCreateCart(sessionId, userId) {
  // Prefer authenticated user cart if logged in
  if (userId) {
    const [existing] = await db.select().from(carts).where(eq(carts.userId, userId));
    if (existing) return existing;
    const [created] = await db.insert(carts).values({ userId }).returning();
    return created;
  }

  // Guest cart by session_id
  const [existing] = await db.select().from(carts).where(eq(carts.sessionId, sessionId));
  if (existing) return existing;
  const [created] = await db.insert(carts).values({ sessionId }).returning();
  return created;
}

module.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.

```
const { products, cartItems, carts } = require('../../shared/schema');
const { eq, and, sql } = require('drizzle-orm');
const { getOrCreateCart } = require('../middleware/session');

// POST /api/cart/add — add item to cart
router.post('/cart/add', async (req, res) => {
  const { productId, quantity = 1 } = req.body;
  const userId = req.user?.id || null;
  const sessionId = req.sessionId;

  // Fetch product and validate stock
  const [product] = await db.select().from(products)
    .where(and(eq(products.id, parseInt(productId)), eq(products.isActive, true)));

  if (!product) return res.status(404).json({ error: 'Product not found' });
  if (product.stockQuantity < quantity) {
    return res.status(400).json({ error: `Only ${product.stockQuantity} in stock` });
  }

  const cart = await getOrCreateCart(sessionId, userId);

  // Check if item already in cart
  const [existing] = await db.select().from(cartItems)
    .where(and(eq(cartItems.cartId, cart.id), eq(cartItems.productId, product.id)));

  if (existing) {
    const newQty = existing.quantity + quantity;
    if (newQty > product.stockQuantity) {
      return res.status(400).json({ error: `Cannot add more than ${product.stockQuantity} total` });
    }
    const [updated] = await db.update(cartItems)
      .set({ quantity: newQty })
      .where(eq(cartItems.id, existing.id))
      .returning();
    return res.json(updated);
  }

  // Snapshot current price at time of adding
  const [item] = await db.insert(cartItems).values({
    cartId: cart.id,
    productId: product.id,
    quantity,
    unitPrice: product.price,  // price snapshot
  }).returning();

  res.status(201).json(item);
});

// PATCH /api/cart/items/:id — update quantity
router.patch('/cart/items/:id', async (req, res) => {
  const { quantity } = req.body;
  if (!quantity || quantity < 1) {
    return res.status(400).json({ error: 'Quantity must be at least 1' });
  }

  const [item] = await db.select().from(cartItems)
    .where(eq(cartItems.id, parseInt(req.params.id)));
  if (!item) return res.status(404).json({ error: 'Cart item not found' });

  const [product] = await db.select().from(products).where(eq(products.id, item.productId));
  if (quantity > product.stockQuantity) {
    return res.status(400).json({ error: `Only ${product.stockQuantity} in stock` });
  }

  const [updated] = await db.update(cartItems)
    .set({ quantity })
    .where(eq(cartItems.id, item.id))
    .returning();

  res.json(updated);
});
```

> 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.

```
const { carts, cartItems, products } = require('../../shared/schema');
const { eq, and } = require('drizzle-orm');

// POST /api/cart/merge — called immediately after Replit Auth login
router.post('/cart/merge', async (req, res) => {
  const userId = req.user?.id;
  if (!userId) return res.status(401).json({ error: 'Login required' });

  const sessionId = req.sessionId;

  // Find the guest cart
  const [guestCart] = await db.select().from(carts).where(eq(carts.sessionId, sessionId));
  if (!guestCart) return res.json({ merged: 0, message: 'No guest cart to merge' });

  // Get or create the authenticated user's cart
  let [userCart] = await db.select().from(carts).where(eq(carts.userId, userId));
  if (!userCart) {
    [userCart] = await db.insert(carts).values({ userId }).returning();
  }

  // Get all items from both carts
  const guestItems = await db.select().from(cartItems).where(eq(cartItems.cartId, guestCart.id));
  const userItems = await db.select().from(cartItems).where(eq(cartItems.cartId, userCart.id));

  let mergedCount = 0;

  for (const guestItem of guestItems) {
    const [product] = await db.select().from(products).where(eq(products.id, guestItem.productId));
    const existing = userItems.find(i => i.productId === guestItem.productId);

    if (existing) {
      // Sum quantities up to stock limit
      const mergedQty = Math.min(existing.quantity + guestItem.quantity, product.stockQuantity);
      await db.update(cartItems)
        .set({ quantity: mergedQty })
        .where(eq(cartItems.id, existing.id));
    } else {
      // Move item to user cart with stock-capped quantity
      const cappedQty = Math.min(guestItem.quantity, product.stockQuantity);
      await db.insert(cartItems).values({
        cartId: userCart.id,
        productId: guestItem.productId,
        quantity: cappedQty,
        unitPrice: guestItem.unitPrice,  // preserve original price snapshot
      });
    }
    mergedCount++;
  }

  // Delete guest cart and all its items
  await db.delete(cartItems).where(eq(cartItems.cartId, guestCart.id));
  await db.delete(carts).where(eq(carts.id, guestCart.id));

  res.json({ merged: mergedCount, cartId: userCart.id });
});
```

> 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 example

File: `server/routes/cart.js`

```javascript
const express = require('express');
const { carts, cartItems, products } = require('../../shared/schema');
const { eq, and, sql } = require('drizzle-orm');
const { db } = require('../db');
const { getOrCreateCart } = require('../middleware/session');

const router = express.Router();

// GET /api/cart — cart with joined product details
router.get('/cart', async (req, res) => {
  const cart = await getOrCreateCart(req.sessionId, req.user?.id || null);
  const items = await db.select({
    id: cartItems.id,
    quantity: cartItems.quantity,
    unitPrice: cartItems.unitPrice,
    productId: products.id,
    productName: products.name,
    productImageUrl: products.imageUrl,
    stockQuantity: products.stockQuantity,
  })
  .from(cartItems)
  .innerJoin(products, eq(cartItems.productId, products.id))
  .where(eq(cartItems.cartId, cart.id));

  const total = items.reduce((sum, i) => sum + i.unitPrice * i.quantity, 0);
  res.json({ items, total, itemCount: items.reduce((sum, i) => sum + i.quantity, 0) });
});

// GET /api/cart/count — lightweight count for header badge
router.get('/cart/count', async (req, res) => {
  const cart = await getOrCreateCart(req.sessionId, req.user?.id || null);
  const [result] = await db.select({ count: sql`SUM(quantity)` })
    .from(cartItems).where(eq(cartItems.cartId, cart.id));
  res.json({ count: parseInt(result.count) || 0 });
});

// DELETE /api/cart/items/:id
router.delete('/cart/items/:id', async (req, res) => {
  await db.delete(cartItems).where(eq(cartItems.id, parseInt(req.params.id)));
  res.json({ deleted: true });
});

module.exports = router;
```

## Common mistakes

- **Not snapshotting the product price at time of adding to cart** — Reading price from the products table at checkout time means any price change between adding and buying changes the cart total unexpectedly — confusing for customers and a potential support issue. Fix: 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.
- **Not merging the guest cart on login** — Without merging, a guest who adds items and then creates an account finds an empty cart. This is one of the most common e-commerce UX failures and causes checkout abandonment. Fix: 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.
- **Validating stock only at checkout instead of at cart add and update** — Without stock validation on cart operations, a user can add 100 units of an item with 5 in stock. The checkout then fails, causing a frustrating experience after the user has already entered their address. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/shopping-cart
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/shopping-cart
