Skip to main content
RapidDev - Software Development Agency

How to Build a Inventory System with Replit

Build a warehouse inventory management system in Replit in 1-2 hours. Track product stock across multiple locations, log every movement (received, shipped, transferred, adjusted), create purchase orders, and get low-stock alerts. All stock updates run in atomic PostgreSQL transactions to prevent negative inventory. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.

What you'll build

  • Product catalog with SKU, reorder point, and cost/price tracking
  • Multi-location stock levels table with atomic update transactions
  • Stock movement log with types: received, shipped, transferred, adjusted, returned
  • Purchase order system with line items and receive workflow that updates stock
  • Low-stock alert query returning products below reorder point by location
  • Inventory valuation report calculating total stock value across all locations
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 warehouse inventory management system in Replit in 1-2 hours. Track product stock across multiple locations, log every movement (received, shipped, transferred, adjusted), create purchase orders, and get low-stock alerts. All stock updates run in atomic PostgreSQL transactions to prevent negative inventory. Uses Express, PostgreSQL with Drizzle ORM, and Replit Auth.

What you're building

Small businesses managing physical inventory often rely on spreadsheets — a fragile system where two simultaneous updates can produce incorrect totals. This project replaces the spreadsheet with a database-backed system that prevents negative stock using atomic PostgreSQL transactions and keeps a complete movement history for audits.

Replit Agent generates the backend in one prompt: products, locations, stock_levels, and stock_movements tables. The stock movement route is the critical path — it wraps the from-location decrement and to-location increment in a single PostgreSQL transaction using Drizzle. If either UPDATE fails (e.g., insufficient stock), the entire transaction rolls back. This eliminates the race condition where two simultaneous shipments both read the same stock level and both succeed.

The app runs on Replit's built-in PostgreSQL with no external services. Multi-location stock is tracked per product-location combination in the stock_levels table. A PostgreSQL view v_low_stock joins products and stock_levels to instantly show what needs reordering. Deploy on Autoscale — inventory systems are used during business hours with predictable traffic.

Final result

A production-ready inventory management system with multi-location stock tracking, movement history, purchase orders, and low-stock alerts — replacing spreadsheets with atomic database operations.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth

Prerequisites

  • A Replit account (free tier is sufficient)
  • Basic understanding of inventory management concepts (stock, SKUs, purchase orders)
  • No external API keys required — everything runs on Replit's built-in PostgreSQL

Build steps

1

Generate the schema and project with Replit Agent

The stock_levels table is the heart of the system — it stores current quantity per product-location combination. Every stock change goes through a movement record, maintaining a complete audit trail.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build an inventory management system with Express and PostgreSQL using Drizzle ORM.
3// Create these tables in shared/schema.ts:
4// - products: id serial pk, sku text unique not null, name text not null,
5// description text, category text, unit_cost integer (cents),
6// selling_price integer, image_url text,
7// reorder_point integer not null default 10,
8// is_active boolean default true, created_at timestamp
9// - locations: id serial pk, name text unique not null, address text,
10// type text default 'warehouse' (warehouse/store/transit)
11// - stock_levels: id serial pk, product_id integer references products not null,
12// location_id integer references locations not null, quantity integer not null default 0,
13// last_counted_at timestamp,
14// UNIQUE constraint on (product_id, location_id)
15// - stock_movements: id serial pk, product_id integer references products not null,
16// from_location_id integer references locations (nullable for received),
17// to_location_id integer references locations (nullable for shipped),
18// quantity integer not null, type text not null (received/shipped/transferred/adjusted/returned),
19// reference_number text, notes text,
20// created_by text not null, created_at timestamp
21// - purchase_orders: id serial pk, supplier_name text not null,
22// items jsonb not null, status text default 'draft' (draft/submitted/received/cancelled),
23// total_cost integer, expected_date timestamp, received_date timestamp,
24// created_by text not null, created_at timestamp
25// Create a PostgreSQL view v_low_stock:
26// SELECT p.sku, p.name, p.reorder_point, sl.location_id, l.name AS location_name,
27// sl.quantity FROM stock_levels sl JOIN products p ON p.id = sl.product_id
28// JOIN locations l ON l.id = sl.location_id WHERE sl.quantity < p.reorder_point
29// Set up Replit Auth. Bind server to 0.0.0.0.

Pro tip: After Agent generates the schema, run the migration and open Drizzle Studio (database icon in sidebar). Create two test locations and one product, then add a stock_levels row manually to verify the unique constraint on (product_id, location_id) works.

Expected result: Agent creates the schema with all tables and the v_low_stock view. Drizzle Studio shows all tables. The view appears under Database → Views.

2

Build the atomic stock movement route

The stock movement creation route is the most critical piece of code in the system. It uses a PostgreSQL transaction to atomically decrement from-location stock and increment to-location stock. If stock goes negative, the transaction rolls back.

server/routes/stock.js
1const { db } = require('../db');
2const { stockMovements, stockLevels, products } = require('../../shared/schema');
3const { eq, and, sql } = require('drizzle-orm');
4
5router.post('/api/stock/movements', async (req, res) => {
6 const { productId, fromLocationId, toLocationId, quantity, type, referenceNumber, notes } = req.body;
7
8 if (!quantity || quantity <= 0) {
9 return res.status(400).json({ error: 'Quantity must be a positive integer' });
10 }
11
12 const product = await db.query.products.findFirst({ where: eq(products.id, Number(productId)) });
13 if (!product) return res.status(404).json({ error: 'Product not found' });
14
15 try {
16 // All operations in a single atomic transaction
17 const movement = await db.transaction(async (tx) => {
18
19 // Decrement from-location stock (if applicable)
20 if (fromLocationId) {
21 // WHERE quantity >= qty prevents negative stock
22 const decrementResult = await tx.execute(
23 sql`UPDATE stock_levels
24 SET quantity = quantity - ${Number(quantity)}
25 WHERE product_id = ${Number(productId)}
26 AND location_id = ${Number(fromLocationId)}
27 AND quantity >= ${Number(quantity)}
28 RETURNING quantity`
29 );
30
31 if (decrementResult.rows.length === 0) {
32 throw new Error('Insufficient stock at source location');
33 }
34 }
35
36 // Increment to-location stock (if applicable)
37 if (toLocationId) {
38 await tx.execute(
39 sql`INSERT INTO stock_levels (product_id, location_id, quantity)
40 VALUES (${Number(productId)}, ${Number(toLocationId)}, ${Number(quantity)})
41 ON CONFLICT (product_id, location_id)
42 DO UPDATE SET quantity = stock_levels.quantity + ${Number(quantity)}`
43 );
44 }
45
46 // Log the movement record
47 const [move] = await tx.insert(stockMovements).values({
48 productId: Number(productId),
49 fromLocationId: fromLocationId ? Number(fromLocationId) : null,
50 toLocationId: toLocationId ? Number(toLocationId) : null,
51 quantity: Number(quantity), type, referenceNumber, notes,
52 createdBy: req.user.id
53 }).returning();
54
55 return move;
56 });
57
58 res.json(movement);
59 } catch (err) {
60 if (err.message.includes('Insufficient stock')) {
61 return res.status(409).json({ error: err.message });
62 }
63 res.status(500).json({ error: err.message });
64 }
65});

Pro tip: The UPDATE ... WHERE quantity >= :qty returning technique is the key to race-condition-free stock management. If two shipments run simultaneously against stock of 5 and both try to ship 5 units, only one succeeds. The other gets an empty RETURNING result, and the transaction rolls back with 'Insufficient stock'.

Expected result: POST /api/stock/movements with a transfer from warehouse to store either succeeds with the new quantities or returns 409 if insufficient stock. Check Drizzle Studio to verify the stock_levels table updated correctly.

3

Build product and location CRUD routes

Products and locations are the master data that stock_levels references. Include the aggregated total stock query that sums across all locations for the product list view.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add these routes to server/routes/products.js:
3//
4// GET /api/products — list products with aggregated stock
5// SELECT p.*, COALESCE(SUM(sl.quantity), 0) AS total_stock,
6// COUNT(sl.id) AS location_count
7// FROM products p LEFT JOIN stock_levels sl ON sl.product_id = p.id
8// WHERE p.is_active = true GROUP BY p.id ORDER BY p.name
9// Return each product with total_stock and a stock_status:
10// 'healthy' if total_stock >= reorder_point * 2
11// 'low' if total_stock >= reorder_point
12// 'critical' if total_stock < reorder_point
13//
14// GET /api/products/:id — single product with per-location stock breakdown
15// Join stock_levels with locations to show: location_name, quantity per location
16//
17// POST /api/products — create product
18// PUT /api/products/:id — update product
19// PATCH /api/products/:id/archive — set is_active=false
20//
21// GET /api/locations — list all locations
22// POST /api/locations — create location
23//
24// GET /api/stock/low — low stock report
25// Query the v_low_stock view
26// Group by product, show all locations where quantity < reorder_point
27//
28// GET /api/reports/valuation — total inventory value
29// SELECT p.name, p.sku, p.unit_cost,
30// SUM(sl.quantity) AS total_quantity,
31// SUM(sl.quantity) * p.unit_cost AS total_value
32// FROM products p JOIN stock_levels sl ON sl.product_id = p.id
33// WHERE p.unit_cost IS NOT NULL
34// GROUP BY p.id ORDER BY total_value DESC

Expected result: GET /api/products returns products with total_stock and stock_status. A product with 5 units and reorder_point=10 shows status='critical'. GET /api/stock/low returns the same product with location details.

4

Add purchase order workflow

Purchase orders track incoming inventory before it arrives. When a PO is received, each line item creates a 'received' stock movement, incrementing stock at the destination location. This keeps the movement history complete.

prompt.txt
1// Prompt to type into Replit Agent:
2// Add purchase order routes to server/routes/orders.js:
3//
4// POST /api/purchase-orders — create PO
5// Body: {supplier_name, items: [{productId, quantity, unitCost}],
6// expected_date, notes}
7// Calculate total_cost = SUM(item.quantity * item.unitCost)
8// INSERT into purchase_orders with status='draft'
9// Return created PO
10//
11// GET /api/purchase-orders — list POs with status filter
12// GET /api/purchase-orders/:id — detail with items expanded (join product names)
13// PATCH /api/purchase-orders/:id/submit — change status from draft to submitted
14//
15// PATCH /api/purchase-orders/:id/receive — mark PO as received
16// Body: {location_id} — which location received the inventory
17// For each item in po.items:
18// Call the stock movement logic (same atomic UPDATE/INSERT as above) with:
19// type='received', to_location_id=location_id, quantity=item.quantity
20// Update PO status to 'received', received_date=now()
21// Return {success: true, movementsCreated: count}
22//
23// PATCH /api/purchase-orders/:id/cancel — cancel draft or submitted PO
24// Only allowed if status is 'draft' or 'submitted'
25// Set status='cancelled'

Expected result: Creating a PO with two line items and receiving it creates two 'received' stock movements and increments the corresponding stock_levels rows. GET /api/products now shows updated totals for both products.

5

Build the React inventory dashboard and deploy

The dashboard shows the inventory health at a glance: critical products highlighted in red, low-stock warnings in yellow, and healthy products in green. Deploy on Autoscale for cost-effective business-hours usage.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build these React components:
3//
4// 1. InventoryDashboard at client/src/pages/InventoryDashboard.jsx:
5// - On mount: fetch GET /api/products, GET /api/stock/low
6// - Summary cards: Total Products, Total SKUs, Critical Stock Items, Locations
7// - Product grid cards: each card shows:
8// * SKU, product name
9// * Total stock quantity with status indicator dot:
10// green=healthy, yellow=low, red=critical
11// * Reorder point reference
12// - Low Stock Alerts panel: list of products below reorder point
13// with 'Create PO' quick action button
14//
15// 2. Stock Movement Form modal:
16// - Movement type selector: Receive / Ship / Transfer / Adjust
17// - Product search/select dropdown
18// - Quantity input
19// - For Transfer: from-location and to-location selectors
20// - For Receive: to-location selector only
21// - For Ship: from-location selector only
22// - Reference number input (PO#, invoice #, etc.)
23// - Notes textarea
24// - Submit → POST /api/stock/movements
25//
26// 3. Movement History page:
27// - Data table with: type badge, product name, quantity, from/to locations, reference, date
28// - Filter by product, location, type, date range
29//
30// Add SESSION_SECRET to Replit Secrets (lock icon)
31// Ensure server binds to 0.0.0.0
32// Deploy → Autoscale

Pro tip: Color-code the stock status dots in the product grid: use Tailwind classes bg-green-500 (healthy), bg-yellow-500 (low = below 2x reorder point), and bg-red-500 (critical = below reorder point). This makes at-a-glance triage instant.

Expected result: The inventory dashboard loads with product cards and color-coded status indicators. The Low Stock Alerts panel lists products needing reorder. The movement form correctly prevents submitting transfers with insufficient stock.

Complete code

server/routes/stock.js
1const { Router } = require('express');
2const { db } = require('../db');
3const { stockMovements, stockLevels, products } = require('../../shared/schema');
4const { eq, sql } = require('drizzle-orm');
5
6const router = Router();
7
8router.post('/api/stock/movements', async (req, res) => {
9 if (!req.user) return res.status(401).json({ error: 'Auth required' });
10 const { productId, fromLocationId, toLocationId, quantity, type, referenceNumber, notes } = req.body;
11
12 if (!quantity || Number(quantity) <= 0) {
13 return res.status(400).json({ error: 'Quantity must be a positive integer' });
14 }
15
16 const product = await db.query.products.findFirst({ where: eq(products.id, Number(productId)) });
17 if (!product) return res.status(404).json({ error: 'Product not found' });
18
19 try {
20 const movement = await db.transaction(async (tx) => {
21 if (fromLocationId) {
22 const dec = await tx.execute(
23 sql`UPDATE stock_levels
24 SET quantity = quantity - ${Number(quantity)}
25 WHERE product_id = ${Number(productId)}
26 AND location_id = ${Number(fromLocationId)}
27 AND quantity >= ${Number(quantity)}
28 RETURNING quantity`
29 );
30 if (dec.rows.length === 0) throw new Error('Insufficient stock at source location');
31 }
32
33 if (toLocationId) {
34 await tx.execute(
35 sql`INSERT INTO stock_levels (product_id, location_id, quantity)
36 VALUES (${Number(productId)}, ${Number(toLocationId)}, ${Number(quantity)})
37 ON CONFLICT (product_id, location_id)
38 DO UPDATE SET quantity = stock_levels.quantity + EXCLUDED.quantity`
39 );
40 }
41
42 const [move] = await tx.insert(stockMovements).values({
43 productId: Number(productId),
44 fromLocationId: fromLocationId ? Number(fromLocationId) : null,
45 toLocationId: toLocationId ? Number(toLocationId) : null,
46 quantity: Number(quantity), type,
47 referenceNumber: referenceNumber || null,
48 notes: notes || null,
49 createdBy: req.user.id
50 }).returning();
51
52 return move;
53 });
54
55 res.json(movement);
56 } catch (err) {
57 const status = err.message.includes('Insufficient stock') ? 409 : 500;
58 res.status(status).json({ error: err.message });
59 }
60});

Customization ideas

Barcode scanning support

Add a barcode field to products. In the movement form, add a camera input using the browser's BarcodeDetector API or a barcode scanning library (html5-qrcode). Scanning a product barcode auto-fills the product field, speeding up physical stock counts.

Stock count / cycle counting

Add a stock_counts table (location_id, counted_by, counted_at) and stock_count_items (count_id, product_id, system_quantity, counted_quantity, variance). A count session compares physical counts to system values and creates 'adjusted' movements for discrepancies.

Low-stock email alerts

A Scheduled Deployment runs daily, queries v_low_stock, and sends an email digest via SendGrid listing products below reorder point with current quantities and suggested order quantities. Store SENDGRID_API_KEY in Replit Secrets.

Common pitfalls

Pitfall: Updating stock_levels in two separate statements instead of a transaction

How to avoid: Use db.transaction() as shown. Drizzle's transaction block commits atomically — either both the decrement and increment succeed, or neither does.

Pitfall: Allowing quantity to go negative on the from-location

How to avoid: The UPDATE includes WHERE quantity >= :qty. If the update affects zero rows (stock is insufficient), throw an error and roll back the transaction before the movement record is inserted.

Pitfall: Storing stock levels as a sum query on movements instead of a dedicated stock_levels table

How to avoid: Maintain stock_levels as a current-state table. Stock movements update it atomically. The current quantity is always a fast indexed lookup.

Best practices

  • Every stock update must go through the stock_movements route — never UPDATE stock_levels directly from the frontend. The movement log is the audit trail.
  • Use PostgreSQL transactions (db.transaction()) for all stock level changes. Never update from-location and to-location in separate SQL statements.
  • The v_low_stock view is a real-time low-stock query — no need to cache or schedule it. Query it on every dashboard load.
  • Use SKU (not product ID) as the human-visible identifier for products. SKUs are stable across migrations and easy to reference in purchase orders and spreadsheet exports.
  • Use Drizzle Studio (database icon in sidebar) to inspect stock_levels during testing. You can see exact quantities per product-location combination and verify movements worked correctly.
  • Deploy on Autoscale — inventory management happens during business hours. Scale-to-zero overnight costs nothing on the free tier.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an inventory management system with Express and PostgreSQL using Drizzle ORM. I have a stock_levels table with columns product_id, location_id, and quantity, with a UNIQUE constraint on (product_id, location_id). I need to build a stock transfer route that atomically: (1) decrements the from-location quantity, (2) increments the to-location quantity, (3) inserts a stock_movements record. The decrement must fail (and roll back the transaction) if quantity would go negative. Help me write this using Drizzle's transaction() API and the UPDATE ... WHERE quantity >= :qty RETURNING technique.

Build Prompt

Add a stock valuation report to the inventory system. Build GET /api/reports/valuation that returns: per-product total quantity (summed across all locations), unit_cost, and total_value (quantity * unit_cost). Also return a summary: grand_total_value (sum of all product values), top_10_by_value (most expensive stock holdings by total value). Format the response for a React data table with sortable columns.

Frequently asked questions

What's the difference between this and inventory-tracking-platform?

This inventory-system tracks aggregate product stock quantities across locations — how many units of Product A are at Warehouse 1. The inventory-tracking-platform tracks individual uniquely-identified assets — which specific laptop (serial: ABC123) is assigned to which employee. Use this system for consumable products; use the tracking platform for durable assets.

How do I handle the opening stock balance when I first set up the system?

Create 'adjusted' stock movements for each product-location combination. Adjusted movements have no from-location (null) and set the initial quantity via the to-location increment. This way the opening balances appear in the movement history like any other stock change.

Can I track stock for e-commerce and decrement on each sale?

Yes. When a sale is confirmed (e.g., Stripe webhook fires checkout.session.completed), call POST /api/stock/movements with type='shipped', from_location_id pointing to your main warehouse, and quantity from the order. Integrate this into your checkout webhook handler.

What happens if a physical stock count doesn't match the system?

Create an 'adjusted' movement with the variance quantity. If the system shows 50 units but you count 48, create a movement: type='adjusted', from_location_id=warehouse, quantity=2, notes='Cycle count adjustment 2026-05-01'. This records the discrepancy without deleting history.

Do I need a paid Replit plan?

No. The free plan includes PostgreSQL, Autoscale deployment, and Replit Auth. The free PostgreSQL tier (10GB) is sufficient for thousands of products and years of movement history.

How do I generate a stock report for a specific date in the past?

Add a created_at filter to the movements table. To reconstruct stock levels at a past date, query: SELECT SUM(CASE WHEN to_location_id = :loc THEN quantity ELSE -quantity END) AS quantity FROM stock_movements WHERE product_id = :pid AND (from_location_id = :loc OR to_location_id = :loc) AND created_at <= :date. This is the movement-log-as-state approach.

Can RapidDev help build a custom inventory system for my business?

Yes. RapidDev has built 600+ apps and can add features like barcode scanning, supplier portal, automated PO generation from low-stock alerts, and integration with e-commerce platforms. Book a free consultation at rapidevelopers.com.

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.