Build a real-time inventory management system in Lovable where stock movements automatically update product levels via a Postgres trigger, low-stock alerts fire through Supabase Realtime, and bulk stock adjustments execute atomically via an Edge Function — complete with a searchable product catalog, movement history, and dashboard charts.
What you're building
Inventory systems have two cardinal rules: stock levels must always be accurate, and every change must be traceable. This build enforces both through a double-entry movement ledger.
Every stock change is an INSERT into stock_movements (product_id, quantity_change, movement_type, reference). A Postgres AFTER INSERT trigger recalculates and updates the product's current_stock column. This means stock_levels is always a derivable value — if the trigger is ever suspected of drift, you can recalculate from the full movements history.
Low-stock alerts use Supabase Realtime. The trigger also checks if the updated stock falls below the product's reorder_point and inserts a row into a low_stock_alerts table. The Lovable frontend subscribes to this table via a Realtime channel and shows a dismissible alert banner without any polling.
Bulk adjustments (e.g. quarterly stocktake corrections) use an Edge Function that wraps multiple stock_movements INSERTs inside a Postgres transaction. If any product in the batch has invalid data, the entire batch rolls back — no partial inventory states.
Final result
A real-time inventory dashboard where every stock change is auditable, low-stock alerts are instant, and bulk operations are atomic.
Tech stack
Prerequisites
- Lovable Pro account for Edge Function generation
- Supabase project with SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in Cloud tab → Secrets
- A list of your product names and SKUs ready to seed the products table
- Understanding of the movement types your business uses (received, sold, adjusted, damaged)
- Optional: an existing orders or sales table to reference from stock movements
Build steps
Create the inventory schema with stock trigger
Ask Lovable to create the tables and the trigger that keeps stock_level in sync with every movement. The trigger is the foundation — all other features rely on it being correct.
1Create an inventory management schema in Supabase.23Tables:4- products: id, sku (text unique), name, description, category, unit_cost (decimal), current_stock (int default 0), reorder_point (int default 10), reorder_quantity (int default 50), location (text), is_active (bool default true), created_at, updated_at5- stock_movements: id, product_id (references products), quantity_change (int, positive = stock in, negative = stock out), movement_type ('received' | 'sold' | 'adjusted' | 'damaged' | 'returned'), reference_id (text, order/PO number), notes (text), created_by (references auth.users), created_at6- low_stock_alerts: id, product_id (references products), current_stock (int), reorder_point (int), resolved_at (timestamptz), created_at78Create a Postgres trigger function update_stock_after_movement() that fires AFTER INSERT on stock_movements:91. UPDATE products SET current_stock = current_stock + NEW.quantity_change, updated_at = now() WHERE id = NEW.product_id102. After update, check if new current_stock < reorder_point. If so, and if no unresolved alert exists for this product, INSERT into low_stock_alerts.1112RLS:13- products: authenticated users can SELECT. Admin role required for INSERT/UPDATE/DELETE.14- stock_movements: authenticated users SELECT and INSERT their own rows.15- low_stock_alerts: authenticated users SELECT, service role INSERT/UPDATE.1617Add a constraint: CHECK(current_stock >= 0) on products to prevent negative stock.Pro tip: The CHECK(current_stock >= 0) constraint will cause the trigger to fail — and roll back the movement — if a sale would result in negative stock. This is a feature: it enforces that you can only sell what you have. To allow backorders, remove the constraint and add a separate backorder_stock column.
Expected result: Tables are created. Inserting a stock_movement with quantity_change=-5 for a product updates current_stock by -5 and inserts a low_stock_alert if stock is now below reorder_point. The TypeScript types are generated.
Set up Realtime low-stock alerts
Ask Lovable to subscribe to the low_stock_alerts table and display a live alert banner. This requires enabling Realtime on the table in Supabase and subscribing on the client.
1Add real-time low-stock alerts to the inventory dashboard.231. In the Supabase dashboard, enable Realtime for the low_stock_alerts table (Supabase will handle this when I ask you to set up the subscription).452. Create a custom hook src/hooks/useLowStockAlerts.ts:6 - Subscribe to INSERT events on the low_stock_alerts table using supabase.channel('low-stock').on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'low_stock_alerts' }, callback).subscribe()7 - Maintain a state array of unresolved alerts (where resolved_at is null)8 - On initial load, fetch existing unresolved alerts9 - When a new alert arrives via Realtime, add it to state10 - Export a dismissAlert(id) function that updates resolved_at = now() and removes from state11123. In the main dashboard layout, render an AlertBanner component:13 - If lowStockAlerts.length > 0, show a yellow Alert (shadcn/ui) at the top14 - Alert content: 'X products are low on stock' with a link to the low-stock filter view15 - Each individual alert can be dismissed (calls dismissAlert)16 - Animate in with a smooth slide-down using CSS transitionExpected result: When a stock_movement causes stock to drop below reorder_point, the low-stock alert appears in the dashboard within 1-2 seconds without a page refresh. Dismissing an alert marks it resolved in the database.
Build the bulk adjustment Edge Function
Stocktakes and bulk corrections need to update many products at once atomically. Build an Edge Function that wraps a batch of movements in a single Postgres transaction.
1// supabase/functions/bulk-adjust-stock/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const corsHeaders = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011type Adjustment = {12 product_id: string13 quantity_change: number14 notes?: string15}1617serve(async (req: Request) => {18 if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })1920 try {21 const supabase = createClient(22 Deno.env.get('SUPABASE_URL') ?? '',23 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''24 )2526 const { adjustments, reference_id, created_by } = await req.json() as {27 adjustments: Adjustment[]28 reference_id: string29 created_by: string30 }3132 if (!adjustments?.length || !reference_id) {33 return new Response(JSON.stringify({ error: 'adjustments and reference_id required' }),34 { status: 400, headers: corsHeaders })35 }3637 const movements = adjustments.map((adj) => ({38 product_id: adj.product_id,39 quantity_change: adj.quantity_change,40 movement_type: 'adjusted',41 reference_id,42 notes: adj.notes ?? 'Bulk stocktake adjustment',43 created_by,44 }))4546 // Supabase inserts in batch — trigger fires per row, all in one transaction47 const { data, error } = await supabase48 .from('stock_movements')49 .insert(movements)50 .select()5152 if (error) throw error5354 return new Response(JSON.stringify({ success: true, inserted: data.length }), { headers: corsHeaders })55 } catch (err) {56 const message = err instanceof Error ? err.message : 'Internal error'57 return new Response(JSON.stringify({ error: message }), { status: 500, headers: corsHeaders })58 }59})Pro tip: Supabase batch INSERTs are not wrapped in a single transaction by default. For true atomicity, create a Postgres function bulk_insert_movements(movements jsonb) using LANGUAGE plpgsql that loops through the array and calls INSERT in a single function body. Functions in Postgres run in an implicit transaction.
Expected result: The Edge Function accepts a JSON array of adjustments and inserts all stock_movements in one operation. If any row fails the CHECK constraint, none are committed.
Build the product catalog and movement history
Ask Lovable to create the two main pages: the product catalog where staff can add products and log single movements, and the movement history with filters.
1Build two pages:231. Products page (src/pages/Products.tsx):4 - DataTable of all active products. Columns: SKU (monospace), Name, Category, Current Stock (red text if below reorder_point), Reorder Point, Location, Actions (Log Movement button)5 - Search Input above table filtering by SKU or name6 - 'Add Product' Button opening a Sheet with a form (react-hook-form + zod): SKU, name, description, category Select (Electronics/Clothing/Food/Other), unit_cost, reorder_point, reorder_quantity, location7 - 'Log Movement' button opens a Dialog: movement type Select (received/sold/adjusted/damaged/returned), quantity Input (positive number), reference_id Input, notes Textarea. Submitting calls supabase.from('stock_movements').insert()8 - Low-stock products row highlighted with amber background9102. Movements page (src/pages/Movements.tsx):11 - DataTable of stock_movements joined with product name12 - Columns: Date, Product Name, SKU, Type Badge (color-coded), Quantity Change (green for positive, red for negative), Reference ID, Notes, Created By13 - Filter bar: product Select, movement_type multi-select, date range Popover with Calendar14 - Export CSV Button that downloads filtered resultsExpected result: Both pages render correctly. Logging a movement from the product page inserts a stock_movements row, the trigger fires, and the current_stock in the product table updates. The movements history reflects the new entry.
Build the dashboard overview
The main dashboard page gives managers a quick health view of the entire inventory. Ask Lovable to build the summary cards and charts.
1Build a dashboard home page at src/pages/Dashboard.tsx.23Layout:4- Row of four stat Cards: Total Products (count), Total Stock Value (SUM of current_stock * unit_cost formatted as currency), Low Stock Items (count WHERE current_stock < reorder_point), Movements Today (count WHERE created_at > today)5- Below cards: two-column layout6 - Left: Recharts BarChart of the 10 products with the lowest stock as a percentage of reorder_point. X-axis = product name (truncated), Y-axis = current_stock. Bars colored red if below reorder_point.7 - Right: recent movements feed — last 10 stock_movements as a simple list showing product name, type Badge, quantity change, and relative time8- Add a 'Quick Add Movement' floating action Button (bottom-right) that opens the Log Movement Dialog without navigating away910All counts and aggregations are Supabase queries run in parallel with Promise.all on component mount. Show Skeleton loading placeholders while data is fetching.Expected result: The dashboard shows live stock health across all cards and charts. The bar chart immediately reflects recent movements. The floating action button opens the movement dialog from any view on the dashboard.
Complete code
1import { useEffect, useState } from 'react'2import { supabase } from '@/integrations/supabase/client'34type LowStockAlert = {5 id: string6 product_id: string7 current_stock: number8 reorder_point: number9 created_at: string10 products: { name: string; sku: string } | null11}1213export function useLowStockAlerts() {14 const [alerts, setAlerts] = useState<LowStockAlert[]>([])15 const [loading, setLoading] = useState(true)1617 useEffect(() => {18 const fetchAlerts = async () => {19 const { data } = await supabase20 .from('low_stock_alerts')21 .select('*, products(name, sku)')22 .is('resolved_at', null)23 .order('created_at', { ascending: false })24 setAlerts(data ?? [])25 setLoading(false)26 }2728 fetchAlerts()2930 const channel = supabase31 .channel('low-stock-alerts')32 .on(33 'postgres_changes',34 { event: 'INSERT', schema: 'public', table: 'low_stock_alerts' },35 async (payload) => {36 const { data } = await supabase37 .from('low_stock_alerts')38 .select('*, products(name, sku)')39 .eq('id', payload.new.id)40 .single()41 if (data) setAlerts((prev) => [data, ...prev])42 }43 )44 .subscribe()4546 return () => { supabase.removeChannel(channel) }47 }, [])4849 const dismissAlert = async (id: string) => {50 await supabase51 .from('low_stock_alerts')52 .update({ resolved_at: new Date().toISOString() })53 .eq('id', id)54 setAlerts((prev) => prev.filter((a) => a.id !== id))55 }5657 return { alerts, loading, dismissAlert }58}Customization ideas
Automated purchase order generation
When a low_stock_alert is created, trigger an Edge Function that generates a purchase_orders row with the product's reorder_quantity and preferred_supplier. Email the purchase order to the supplier using Resend. The admin can approve or modify it from a purchase orders page before it becomes official.
Barcode scanning support
Add a barcode field to products. On the mobile view of the Log Movement page, add a camera input that reads barcodes using a WebRTC-based library. Scanning a barcode auto-populates the product selector, making warehouse operations much faster on mobile devices.
Multi-location inventory
Add a locations table and a location_id column to both products and stock_movements. Stock levels become per-location. Add a transfer movement type that creates a negative movement at the source location and a positive one at the destination location, wrapped in the bulk adjust Edge Function.
Supplier and purchase order tracking
Add suppliers and purchase_orders tables. Link purchase orders to stock movements with movement_type='received'. When you receive a purchase order, create movements for each line item. This gives you a complete inbound supply chain view.
Inventory valuation reports
Add a reports page with a monthly valuation table: each product, average cost (using FIFO or weighted average from movements), units, and total value. Export as PDF using the browser's print API. This satisfies common accounting requirements for inventory-heavy businesses.
Common pitfalls
Pitfall: Updating current_stock directly instead of going through stock_movements
How to avoid: Treat current_stock as a read-only computed value. All stock changes must be INSERTs into stock_movements. Deny direct UPDATE on products.current_stock by removing it from the RLS UPDATE policy columns list.
Pitfall: Forgetting to enable Realtime for the low_stock_alerts table
How to avoid: In your Supabase project, go to Database → Replication and add the low_stock_alerts table to the publication. Ask Lovable to include this in the setup prompt or enable it manually in the Supabase dashboard.
Pitfall: Not handling the case where a sale creates negative stock
How to avoid: In the Log Movement form, check available stock before submitting. In the bulk adjustment Edge Function, catch the constraint violation error and return a user-friendly message: 'Product [name] would go negative. Movement rejected.'
Pitfall: Using the service role key in client-side code
How to avoid: The service role key is only for Edge Functions (accessed via Deno.env.get). All client-side Supabase queries use the anon key. The anon key is safe to expose because RLS policies control what data is accessible.
Best practices
- Never allow direct updates to the current_stock column from the application. All stock changes flow through stock_movements so the ledger is always complete and auditable.
- Add a not-null reference_id requirement for movements of type 'sold' or 'received'. This ensures every stock change can be traced back to an order or purchase order.
- Use optimistic updates in the UI for single movements: update the local state immediately, then confirm with the database response. If the database rejects the movement (e.g. negative stock), revert and show an error.
- Index stock_movements on (product_id, created_at DESC) for fast movement history queries and on (created_at) for the dashboard's today count.
- Set up a Supabase alert or pg_cron job that recalculates current_stock from the movements ledger weekly as a consistency check. Log any discrepancies to an audit table.
- Export the bulk adjustment template as a CSV download. Staff fill in the spreadsheet and upload it. Parse the CSV in the browser and preview changes before submitting to the Edge Function.
- Use Row Level Security to restrict which users can log which movement types. Warehouse staff can log 'received' and 'damaged'. Only managers can log 'adjusted'. Implement this via a role check in the movements INSERT policy.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an inventory system in Supabase where stock levels are maintained via a trigger on a stock_movements table. I need a Postgres trigger function update_stock_after_movement() that fires AFTER INSERT on stock_movements, updates the product's current_stock, and also inserts a low_stock_alert if the new stock is below reorder_point (but only if no unresolved alert already exists). Show me the full plpgsql trigger function.
Add a stocktake page at /stocktake. It should load all active products and display them in an editable grid with columns: SKU, Name, Current Stock (read-only), Counted Stock (number Input editable by user), Difference (auto-calculated Current minus Counted, shown in red/green). At the bottom, show a summary: X products with discrepancies, total units variance. A Submit Stocktake Button sends all non-zero differences to the bulk-adjust-stock Edge Function with movement_type='adjusted' and reference_id='STOCKTAKE-{date}'.
In Supabase, create a pg_cron job that runs every Monday at 8am. It should query all products where current_stock < reorder_point and resolved_at is null in low_stock_alerts, then insert rows into a weekly_reorder_report table with product_id, current_stock, reorder_point, reorder_quantity, and report_date = current_date. This table can be read by an Edge Function that emails the purchasing manager a formatted reorder list every Monday morning.
Frequently asked questions
How does the stock trigger handle concurrent movements?
Postgres processes each INSERT into stock_movements serially within the transaction. The trigger uses UPDATE products SET current_stock = current_stock + NEW.quantity_change, which is an atomic increment. If two movements for the same product arrive simultaneously, Postgres serializes them at the row lock level, so neither is lost or double-applied.
What if I need to allow negative stock for backorders?
Remove the CHECK(current_stock >= 0) constraint from the products table. Add a separate column allow_backorder (boolean default false) per product. Modify the trigger to only enforce non-negative stock when allow_backorder is false. The UI can show negative stock in a distinct color to differentiate it from normal levels.
How do I seed the products table with my existing inventory data?
Prepare a CSV with columns matching your products table schema. In the Supabase dashboard, go to Table Editor → products → Import Data and upload the CSV. Alternatively, ask Lovable to build a CSV import page that parses the file client-side and batches INSERT calls in groups of 100 rows.
Can Supabase Realtime handle a high volume of stock movements?
Supabase Realtime is suitable for alert-level notifications (low-stock events are infrequent). For the movement feed in the dashboard, polling every 30 seconds is more appropriate than Realtime for high-volume systems, as Realtime connections are limited on the free tier. Use Realtime for the low_stock_alerts table and polling for movement history.
How do I connect inventory to an order management system?
Create an Edge Function process-order that accepts an array of line items (product_id, quantity). For each item, it inserts a stock_movement with type='sold' and the order ID as reference_id. Call this function from your order checkout flow after payment is confirmed. If any product has insufficient stock, the entire function should return an error before any movements are inserted.
How do I generate a snapshot of current inventory for accounting?
The dashboard's Export CSV button should query SELECT sku, name, current_stock, unit_cost, (current_stock * unit_cost) as total_value FROM products WHERE is_active = true ORDER BY category, name. Format it as CSV in the browser and trigger a download. Include the export timestamp in the filename for version control.
What is the best way to track inventory that has an expiry date?
Add an expiry_date column to stock_movements (for received items) and to a separate inventory_lots table. When stock is sold, consume the oldest lots first (FIFO). Add a pg_cron job that checks daily for lots expiring within 30 days and creates low_stock_alerts with type='expiring'. The movement history then shows which lot each unit came from.
Is there help available for building a more complex inventory system?
RapidDev builds Lovable apps with complex backend logic including multi-location inventory, supplier portals, and ERP integrations. Reach out if your inventory requirements go beyond this guide.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation