# How to Build an Inventory Tracking Platform with Replit

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

## TL;DR

Build an inventory tracking platform in Replit in 1-2 hours. Use Replit Agent to generate an Express + PostgreSQL app that tracks individual assets — laptops, equipment, tools — with unique asset tags, assignment history, QR codes, and maintenance schedules. No coding experience needed. Deploy on Autoscale.

## Before you start

- A Replit account (Free plan is sufficient)
- Basic understanding of what a database table is (no coding experience needed)
- List of asset categories you want to track (equipment, vehicles, electronics, etc.)
- Optional: OpenCage or Mapbox API key if you want geocoded location support

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Open Replit, create a new Repl, and use the Agent prompt below to generate the full Express + PostgreSQL project structure. Agent will create the Drizzle schema, routes, and React frontend in one shot.

```
// Type this into Replit Agent:
// Build an asset tracking platform with Express and PostgreSQL using Drizzle ORM.
// Create a shared/schema.ts with these tables:
// - assets: id serial primary key, asset_tag text not null unique (format AST-XXXX),
//   name text not null, description text, category text not null (enum: equipment/vehicle/furniture/electronics/tools),
//   serial_number text, purchase_date date, purchase_cost integer, warranty_expiry date,
//   condition text default 'good' (enum: new/good/fair/poor/retired), image_url text,
//   qr_code_data text unique, created_at timestamp default now()
// - locations: id serial, name text not null, building text, floor text, room text
// - asset_assignments: id serial, asset_id integer references assets, assigned_to text not null,
//   assigned_by text not null, location_id integer references locations, assigned_at timestamp default now(),
//   returned_at timestamp, notes text
// - maintenance_records: id serial, asset_id integer references assets, type text not null
//   (enum: inspection/repair/calibration/replacement), description text not null,
//   cost integer, performed_by text, scheduled_date date, completed_date date, next_due_date date,
//   created_at timestamp default now()
// - audit_log: id serial, asset_id integer references assets, action text not null,
//   performed_by text not null, details jsonb, created_at timestamp default now()
// Create Express routes for all CRUD operations plus:
// - POST /api/assets/:id/assign (check out to a person)
// - POST /api/assets/:id/return (check back in)
// - GET /api/assets/due-maintenance (overdue items)
// - GET /api/assets/:tag/qr (lookup by asset tag for QR scanning)
// Use Replit Auth for authentication.
// Build a React frontend with an asset registry table, asset detail page,
// checkout/return form, and maintenance dashboard.
```

> Pro tip: After Agent finishes, open Drizzle Studio (the database icon in the Replit sidebar) to verify all five tables were created correctly before adding any data.

**Expected result:** A running Express app with all tables created and a React frontend showing the asset registry. The console shows 'Server running on port 5000'.

### 2. Add the asset tag generator and QR code creator

Every asset needs a unique human-readable tag (AST-0042) that can be printed on a label and scanned later. Add the auto-increment tag generator and QR code data creator to the POST /api/assets route.

```
const express = require('express');
const { db } = require('../db');
const { assets, auditLog } = require('../../shared/schema');
const { eq, sql } = require('drizzle-orm');

const router = express.Router();

// Generate next asset tag like AST-0042
async function generateAssetTag() {
  const result = await db.select({ max: sql`MAX(CAST(SUBSTRING(asset_tag FROM 5) AS INTEGER))` }).from(assets);
  const next = (result[0].max || 0) + 1;
  return `AST-${String(next).padStart(4, '0')}`;
}

// POST /api/assets — create new asset with auto tag and QR data
router.post('/', async (req, res) => {
  try {
    const assetTag = await generateAssetTag();
    const qrCodeData = `${process.env.REPLIT_DEPLOYMENT_URL || 'http://localhost:5000'}/api/assets/${assetTag}/qr`;

    const [asset] = await db.insert(assets).values({
      ...req.body,
      assetTag,
      qrCodeData,
    }).returning();

    await db.insert(auditLog).values({
      assetId: asset.id,
      action: 'created',
      performedBy: req.user?.name || 'system',
      details: { assetTag },
    });

    res.status(201).json(asset);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// GET /api/assets/:tag/qr — lookup by QR scan
router.get('/:tag/qr', async (req, res) => {
  const [asset] = await db.select().from(assets).where(eq(assets.assetTag, req.params.tag));
  if (!asset) return res.status(404).json({ error: 'Asset not found' });
  res.json(asset);
});

module.exports = router;
```

> Pro tip: Install the `qrcode` npm package by typing `/qrcode` in the Replit packages panel. Then add a GET /api/assets/:id/qr-image route that generates an SVG QR code — this is what you print on physical labels.

**Expected result:** Creating a new asset returns a JSON object with an auto-generated asset_tag like 'AST-0001' and a qr_code_data URL that resolves to the asset's detail page.

### 3. Build the assignment checkout and return workflow

The core of asset tracking is knowing who has what right now. The assign route closes any open assignment first, then opens a new one. The return route sets returned_at on the current assignment.

```
const { assetAssignments, assets, auditLog } = require('../../shared/schema');
const { eq, isNull, and } = require('drizzle-orm');

// POST /api/assets/:id/assign
router.post('/:id/assign', async (req, res) => {
  const assetId = parseInt(req.params.id);
  const { assignedTo, locationId, notes } = req.body;

  try {
    // Close any open assignment
    await db.update(assetAssignments)
      .set({ returnedAt: new Date() })
      .where(and(
        eq(assetAssignments.assetId, assetId),
        isNull(assetAssignments.returnedAt)
      ));

    // Create new assignment
    const [assignment] = await db.insert(assetAssignments).values({
      assetId,
      assignedTo,
      assignedBy: req.user?.name || 'unknown',
      locationId: locationId || null,
      notes: notes || null,
    }).returning();

    await db.insert(auditLog).values({
      assetId,
      action: 'assigned',
      performedBy: req.user?.name || 'unknown',
      details: { assignedTo, locationId },
    });

    res.status(201).json(assignment);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

// POST /api/assets/:id/return
router.post('/:id/return', async (req, res) => {
  const assetId = parseInt(req.params.id);

  const [updated] = await db.update(assetAssignments)
    .set({ returnedAt: new Date() })
    .where(and(
      eq(assetAssignments.assetId, assetId),
      isNull(assetAssignments.returnedAt)
    ))
    .returning();

  if (!updated) return res.status(404).json({ error: 'No active assignment found' });

  await db.insert(auditLog).values({
    assetId,
    action: 'returned',
    performedBy: req.user?.name || 'unknown',
    details: {},
  });

  res.json(updated);
});

module.exports = router;
```

**Expected result:** POST /api/assets/1/assign returns the new assignment record. POST /api/assets/1/return closes the open assignment and sets returned_at to now.

### 4. Add the maintenance scheduler and overdue alerts

Maintenance records track service history and schedule future work. The GET /api/assets/due-maintenance endpoint returns assets where any maintenance record's next_due_date is today or earlier — this powers the overdue dashboard.

```
const { maintenanceRecords, assets } = require('../../shared/schema');
const { lte, eq } = require('drizzle-orm');

// GET /api/assets/due-maintenance
router.get('/due-maintenance', async (req, res) => {
  const today = new Date().toISOString().split('T')[0];

  const overdue = await db
    .select({
      assetId: assets.id,
      assetTag: assets.assetTag,
      assetName: assets.name,
      category: assets.category,
      maintenanceType: maintenanceRecords.type,
      nextDueDate: maintenanceRecords.nextDueDate,
      lastCompleted: maintenanceRecords.completedDate,
    })
    .from(maintenanceRecords)
    .innerJoin(assets, eq(maintenanceRecords.assetId, assets.id))
    .where(lte(maintenanceRecords.nextDueDate, today));

  res.json(overdue);
});

// POST /api/assets/:id/maintenance — log or schedule maintenance
router.post('/:id/maintenance', async (req, res) => {
  const assetId = parseInt(req.params.id);
  const { type, description, cost, performedBy, scheduledDate, completedDate, nextDueDate } = req.body;

  const [record] = await db.insert(maintenanceRecords).values({
    assetId,
    type,
    description,
    cost: cost || null,
    performedBy: performedBy || null,
    scheduledDate: scheduledDate || null,
    completedDate: completedDate || null,
    nextDueDate: nextDueDate || null,
  }).returning();

  res.status(201).json(record);
});
```

> Pro tip: Add a compound index on maintenance_records(asset_id, next_due_date) in your Drizzle schema using `index('idx_maint_due').on(maintenanceRecords.assetId, maintenanceRecords.nextDueDate)` to keep the overdue query fast as records accumulate.

**Expected result:** GET /api/assets/due-maintenance returns an array of assets with overdue maintenance. An empty array means all assets are up to date.

### 5. Add the retry wrapper and deploy on Autoscale

Replit's built-in PostgreSQL goes idle after 5 minutes of inactivity. Add a retry wrapper around DB connection calls so the first request after idle wakes the database gracefully. Then deploy on Autoscale.

```
// server/db.js — DB helper with retry wrapper
const { drizzle } = require('drizzle-orm/node-postgres');
const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 5000,
});

exports.db = drizzle(pool);

// Retry wrapper for the first query after DB idle
exports.withRetry = async (fn, retries = 3) => {
  for (let attempt = 1; attempt <= retries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const isConnectionError = err.code === 'ECONNREFUSED' || err.code === '57P03';
      if (isConnectionError && attempt < retries) {
        console.log(`DB connection attempt ${attempt} failed, retrying...`);
        await new Promise(r => setTimeout(r, 1000 * attempt));
      } else {
        throw err;
      }
    }
  }
};

// server/index.js — bind to 0.0.0.0 for Replit
const app = require('express')();
app.listen(5000, '0.0.0.0', () => console.log('Asset tracker running on port 5000'));
```

> Pro tip: To deploy: click the Deploy button in the Replit toolbar, choose Autoscale, set the run command to `node server/index.js`. Autoscale scales to zero when idle, which is perfect for asset tracking — it's used during check-in/check-out cycles, not 24/7.

**Expected result:** The app deploys to a public URL. The retry wrapper handles the first DB query after any idle period without surfacing a 500 error to users.

## Complete code example

File: `shared/schema.js`

```javascript
const { pgTable, serial, text, integer, numeric, boolean, timestamp, date, jsonb } = require('drizzle-orm/pg-core');

exports.assets = pgTable('assets', {
  id: serial('id').primaryKey(),
  assetTag: text('asset_tag').notNull().unique(),
  name: text('name').notNull(),
  description: text('description'),
  category: text('category').notNull(),
  serialNumber: text('serial_number'),
  purchaseDate: date('purchase_date'),
  purchaseCost: integer('purchase_cost'),
  warrantyExpiry: date('warranty_expiry'),
  condition: text('condition').default('good'),
  imageUrl: text('image_url'),
  qrCodeData: text('qr_code_data').unique(),
  createdAt: timestamp('created_at').defaultNow(),
});

exports.locations = pgTable('locations', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  building: text('building'),
  floor: text('floor'),
  room: text('room'),
});

exports.assetAssignments = pgTable('asset_assignments', {
  id: serial('id').primaryKey(),
  assetId: integer('asset_id').references(() => exports.assets.id).notNull(),
  assignedTo: text('assigned_to').notNull(),
  assignedBy: text('assigned_by').notNull(),
  locationId: integer('location_id').references(() => exports.locations.id),
  assignedAt: timestamp('assigned_at').defaultNow(),
  returnedAt: timestamp('returned_at'),
  notes: text('notes'),
});

exports.maintenanceRecords = pgTable('maintenance_records', {
  id: serial('id').primaryKey(),
  assetId: integer('asset_id').references(() => exports.assets.id).notNull(),
  type: text('type').notNull(),
  description: text('description').notNull(),
  cost: integer('cost'),
  performedBy: text('performed_by'),
  scheduledDate: date('scheduled_date'),
  completedDate: date('completed_date'),
  nextDueDate: date('next_due_date'),
  createdAt: timestamp('created_at').defaultNow(),
});

exports.auditLog = pgTable('audit_log', {
  id: serial('id').primaryKey(),
  assetId: integer('asset_id').references(() => exports.assets.id),
  action: text('action').notNull(),
  performedBy: text('performed_by').notNull(),
  details: jsonb('details'),
  createdAt: timestamp('created_at').defaultNow(),
});
```

## Common mistakes

- **Assigning an asset without closing the previous assignment** — Calling POST /api/assets/:id/assign without first checking for an open assignment creates two concurrent assignments for the same asset, making the history unreliable. Fix: The assign route always runs an UPDATE to close any open assignment (returnedAt IS NULL) before creating the new one. This is handled in a single DB transaction to prevent race conditions.
- **Hardcoding the asset tag URL in QR code data** — If you hardcode localhost:5000 as the QR code URL, printed labels stop working when the app moves to a production URL. Fix: Use process.env.REPLIT_DEPLOYMENT_URL to build the QR code URL. Add this to Replit Secrets so it updates when you redeploy to a custom domain.
- **Not adding an index on asset_tag for QR scan lookups** — QR scan lookups query by asset_tag on every scan. Without an index, each scan does a full table scan that slows down as the asset registry grows. Fix: The unique constraint on asset_tag automatically creates a B-tree index in PostgreSQL. Verify it exists using Drizzle Studio's schema view.
- **Forgetting the DB retry wrapper for the first query after idle** — Replit's PostgreSQL sleeps after 5 minutes. The first query after sleep throws a connection error instead of waiting for the DB to wake. Fix: Wrap all DB calls in the withRetry helper that catches ECONNREFUSED errors and retries with exponential backoff up to 3 times.

## Best practices

- Store REPLIT_DEPLOYMENT_URL in Replit Secrets (lock icon) so QR code URLs automatically point to your production domain after deployment.
- Use Drizzle Studio (database icon in Replit sidebar) to bulk-import your initial asset list by pasting rows directly into the table editor.
- Add a unique constraint on asset_tag in the Drizzle schema — Drizzle enforces this at the database level, not just in application code.
- Log every action (create, assign, return, maintenance) to the audit_log table so you have a complete chain of custody for each asset.
- Use the Haversine formula in a custom SQL query if you need to find all assets within a geographic radius of a location.
- Deploy on Autoscale for asset tracking — usage is bursty (during audits and check-in/out cycles) rather than continuous, so Autoscale's scale-to-zero saves cost.
- Print QR labels using a Brother or Zebra label printer connected to any computer — the QR code SVG from the qrcode package can be sent directly to a label printer.

## Frequently asked questions

### Can I import my existing asset list from a spreadsheet?

Yes. Export your spreadsheet as CSV, then add a POST /api/assets/import route that uses the csv-parse npm package to read the file and bulk-insert rows. Drizzle's db.insert().values(arrayOfRows) handles batch inserts in one query. Asset tags and QR codes are auto-generated for each new row.

### How do I generate printable QR code labels?

Install the qrcode npm package. Add a GET /api/assets/:id/qr-svg route that returns an SVG QR code. On the React frontend, show a Print Labels button that renders selected assets' QR SVGs in a grid and triggers window.print(). Use CSS @media print to hide all UI except the label grid.

### What Replit plan do I need?

The Free plan is sufficient to build and test the app. For deployment with a public URL, Replit Autoscale works on all paid plans starting at Core. The built-in PostgreSQL is available on all plans including Free during development.

### How does the assignment history work for compliance audits?

Every assignment record has a returned_at timestamp. To see the full history of an asset, query asset_assignments WHERE asset_id = ? ORDER BY assigned_at DESC. The audit_log table also records every action with performer name and timestamp. Both tables are queryable in Drizzle Studio.

### How do I handle assets that are permanently retired?

Set the asset's condition to 'retired' via PATCH /api/assets/:id. Add a filter to the default asset list query to exclude retired assets: WHERE condition != 'retired'. Create a separate Retired Assets view in the frontend for the rare case when you need to look up historical records.

### Can multiple people use the app simultaneously?

Yes. Replit's PostgreSQL handles concurrent connections. The assign route uses a WHERE returnedAt IS NULL check to prevent double-assignments. For high-concurrency scenarios, add a SELECT ... FOR UPDATE lock on the asset row before the assignment update.

### Can RapidDev help build a custom asset tracking platform?

Yes. RapidDev has built 600+ apps including inventory and operations tools. They can add custom workflows like multi-site tracking, approval flows for high-value assets, or integrations with your existing ERP system. Book a free consultation at rapidevelopers.com.

### What happens if the database goes idle and a user scans a QR code?

The withRetry wrapper in server/db.js catches the connection error on the first query after idle and retries up to 3 times with a 1-second delay between attempts. The QR scan typically succeeds on the second attempt, with a ~2 second delay that users rarely notice.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/inventory-tracking-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/inventory-tracking-platform
