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
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
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.
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 timestamp9// - 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 timestamp21// - 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 timestamp25// 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_id28// JOIN locations l ON l.id = sl.location_id WHERE sl.quantity < p.reorder_point29// 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.
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.
1const { db } = require('../db');2const { stockMovements, stockLevels, products } = require('../../shared/schema');3const { eq, and, sql } = require('drizzle-orm');45router.post('/api/stock/movements', async (req, res) => {6 const { productId, fromLocationId, toLocationId, quantity, type, referenceNumber, notes } = req.body;78 if (!quantity || quantity <= 0) {9 return res.status(400).json({ error: 'Quantity must be a positive integer' });10 }1112 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' });1415 try {16 // All operations in a single atomic transaction17 const movement = await db.transaction(async (tx) => {1819 // Decrement from-location stock (if applicable)20 if (fromLocationId) {21 // WHERE quantity >= qty prevents negative stock22 const decrementResult = await tx.execute(23 sql`UPDATE stock_levels24 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 );3031 if (decrementResult.rows.length === 0) {32 throw new Error('Insufficient stock at source location');33 }34 }3536 // 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 }4546 // Log the movement record47 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.id53 }).returning();5455 return move;56 });5758 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.
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.
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 stock5// SELECT p.*, COALESCE(SUM(sl.quantity), 0) AS total_stock,6// COUNT(sl.id) AS location_count7// FROM products p LEFT JOIN stock_levels sl ON sl.product_id = p.id8// WHERE p.is_active = true GROUP BY p.id ORDER BY p.name9// Return each product with total_stock and a stock_status:10// 'healthy' if total_stock >= reorder_point * 211// 'low' if total_stock >= reorder_point12// 'critical' if total_stock < reorder_point13//14// GET /api/products/:id — single product with per-location stock breakdown15// Join stock_levels with locations to show: location_name, quantity per location16//17// POST /api/products — create product18// PUT /api/products/:id — update product19// PATCH /api/products/:id/archive — set is_active=false20//21// GET /api/locations — list all locations22// POST /api/locations — create location23//24// GET /api/stock/low — low stock report25// Query the v_low_stock view26// Group by product, show all locations where quantity < reorder_point27//28// GET /api/reports/valuation — total inventory value29// SELECT p.name, p.sku, p.unit_cost,30// SUM(sl.quantity) AS total_quantity,31// SUM(sl.quantity) * p.unit_cost AS total_value32// FROM products p JOIN stock_levels sl ON sl.product_id = p.id33// WHERE p.unit_cost IS NOT NULL34// GROUP BY p.id ORDER BY total_value DESCExpected 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.
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.
1// Prompt to type into Replit Agent:2// Add purchase order routes to server/routes/orders.js:3//4// POST /api/purchase-orders — create PO5// 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 PO10//11// GET /api/purchase-orders — list POs with status filter12// GET /api/purchase-orders/:id — detail with items expanded (join product names)13// PATCH /api/purchase-orders/:id/submit — change status from draft to submitted14//15// PATCH /api/purchase-orders/:id/receive — mark PO as received16// Body: {location_id} — which location received the inventory17// 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.quantity20// 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 PO24// 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.
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.
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/low6// - Summary cards: Total Products, Total SKUs, Critical Stock Items, Locations7// - Product grid cards: each card shows:8// * SKU, product name9// * Total stock quantity with status indicator dot:10// green=healthy, yellow=low, red=critical11// * Reorder point reference12// - Low Stock Alerts panel: list of products below reorder point13// with 'Create PO' quick action button14//15// 2. Stock Movement Form modal:16// - Movement type selector: Receive / Ship / Transfer / Adjust17// - Product search/select dropdown18// - Quantity input19// - For Transfer: from-location and to-location selectors20// - For Receive: to-location selector only21// - For Ship: from-location selector only22// - Reference number input (PO#, invoice #, etc.)23// - Notes textarea24// - Submit → POST /api/stock/movements25//26// 3. Movement History page:27// - Data table with: type badge, product name, quantity, from/to locations, reference, date28// - Filter by product, location, type, date range29//30// Add SESSION_SECRET to Replit Secrets (lock icon)31// Ensure server binds to 0.0.0.032// Deploy → AutoscalePro 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
1const { Router } = require('express');2const { db } = require('../db');3const { stockMovements, stockLevels, products } = require('../../shared/schema');4const { eq, sql } = require('drizzle-orm');56const router = Router();78router.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;1112 if (!quantity || Number(quantity) <= 0) {13 return res.status(400).json({ error: 'Quantity must be a positive integer' });14 }1516 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' });1819 try {20 const movement = await db.transaction(async (tx) => {21 if (fromLocationId) {22 const dec = await tx.execute(23 sql`UPDATE stock_levels24 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 }3233 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 }4142 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.id50 }).returning();5152 return move;53 });5455 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation