Back to Blog

Build an Inventory Tracking System with QR Codes

QRWorks Team11 min read

Where is laptop #247? Who checked out the projector? When was this equipment last serviced?

Static labels tell you nothing. A dynamic QR code on each asset creates a complete activity log: every scan, every location, every timestamp.

In this tutorial, you'll build an asset tracking system that:

  • Generates unique QR codes for each piece of equipment
  • Logs check-outs, returns, and location updates
  • Tracks maintenance history via scan records
  • Provides real-time asset location reports

Use cases

This architecture works for:

IndustryAssets TrackedKey Metric
IT departmentsLaptops, monitors, cablesWho has what
WarehousesPallets, bins, shipmentsCurrent location
ConstructionTools, equipment, materialsLast known position
HealthcareMedical devices, suppliesMaintenance schedule
SchoolsTextbooks, Chromebooks, AV gearCheck-out status

System architecture

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Asset     │────▶│  QRWorks    │────▶│  Your App   │
│   Label     │     │  Redirect   │     │  Handler    │
└─────────────┘     └─────────────┘     └─────────────┘
       │                                       │
       │                                       ▼
       │                              ┌─────────────┐
       │                              │  Database   │
       │                              │  (assets,   │
       │                              │   scans)    │
       │                              └─────────────┘
       │                                       │
       ▼                                       ▼
┌─────────────┐                       ┌─────────────┐
│  Analytics  │◀──────────────────────│  Location   │
│  Dashboard  │                       │  History    │
└─────────────┘                       └─────────────┘

Staff scan the QR code to:

  1. Check out equipment to themselves
  2. Return equipment to inventory
  3. Report issues or request maintenance
  4. Update location (moved to a new room/site)

Database schema

-- assets table
CREATE TABLE assets (
  id UUID PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  category VARCHAR(100),
  serial_number VARCHAR(100),
  qr_analytics_id VARCHAR(100),
  status VARCHAR(50) DEFAULT 'available',
  current_location VARCHAR(255),
  assigned_to VARCHAR(255),
  created_at TIMESTAMP DEFAULT NOW()
);

-- asset_events table (audit log)
CREATE TABLE asset_events (
  id UUID PRIMARY KEY,
  asset_id UUID REFERENCES assets(id),
  event_type VARCHAR(50), -- checkout, return, move, maintenance
  performed_by VARCHAR(255),
  location VARCHAR(255),
  notes TEXT,
  scan_data JSONB, -- raw scan info from QRWorks
  created_at TIMESTAMP DEFAULT NOW()
);

Generate asset QR codes

When adding new equipment to inventory:

// assets.js
import fetch from 'node-fetch';

const API_KEY = process.env.QRWORKS_API_KEY;
const BASE_URL = process.env.QRWORKS_BASE_URL;

async function createAssetQR(asset) {
  // URL points to your asset handler
  const assetUrl = `https://yourapp.com/asset/${asset.id}`;

  const response = await fetch(`${BASE_URL}/v1/generate/dynamic`, {
    method: 'POST',
    headers: {
      'X-API-Key': API_KEY,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      redirect_url: assetUrl,
      metadata: {
        asset_id: asset.id,
        asset_name: asset.name,
        category: asset.category,
        serial_number: asset.serialNumber
      }
    })
  });

  const data = await response.json();

  // Save analytics ID to database
  await db.query(
    'UPDATE assets SET qr_analytics_id = $1 WHERE id = $2',
    [data.analytics_id, asset.id]
  );

  return {
    assetId: asset.id,
    qrCodeUrl: data.qr_code_url,
    analyticsId: data.analytics_id
  };
}

Asset scan handler

When someone scans an asset's QR code:

// server.js
import express from 'express';

const app = express();
app.use(express.json());

app.get('/asset/:assetId', async (req, res) => {
  const { assetId } = req.params;

  // Get asset details
  const asset = await db.query(
    'SELECT * FROM assets WHERE id = $1',
    [assetId]
  ).then(r => r.rows[0]);

  if (!asset) {
    return res.status(404).json({ error: 'Asset not found' });
  }

  // Log the scan
  const scanInfo = {
    ip: req.headers['x-forwarded-for'] || req.ip,
    userAgent: req.headers['user-agent'],
    timestamp: new Date().toISOString()
  };

  // Return asset info and action options
  res.json({
    asset: {
      id: asset.id,
      name: asset.name,
      category: asset.category,
      serialNumber: asset.serial_number,
      status: asset.status,
      currentLocation: asset.current_location,
      assignedTo: asset.assigned_to
    },
    actions: getAvailableActions(asset),
    scanInfo
  });
});

function getAvailableActions(asset) {
  const actions = [];

  if (asset.status === 'available') {
    actions.push({ action: 'checkout', label: 'Check out this item' });
  }

  if (asset.status === 'checked_out') {
    actions.push({ action: 'return', label: 'Return this item' });
  }

  actions.push({ action: 'move', label: 'Update location' });
  actions.push({ action: 'maintenance', label: 'Report issue' });
  actions.push({ action: 'history', label: 'View history' });

  return actions;
}

Check-out and return flows

app.post('/asset/:assetId/checkout', async (req, res) => {
  const { assetId } = req.params;
  const { userId, userName, location } = req.body;

  // Update asset status
  await db.query(`
    UPDATE assets
    SET status = 'checked_out',
        assigned_to = $1,
        current_location = $2
    WHERE id = $3
  `, [userName, location, assetId]);

  // Log event
  await db.query(`
    INSERT INTO asset_events (id, asset_id, event_type, performed_by, location)
    VALUES ($1, $2, 'checkout', $3, $4)
  `, [generateId(), assetId, userName, location]);

  res.json({
    success: true,
    message: `Checked out to ${userName}`
  });
});

app.post('/asset/:assetId/return', async (req, res) => {
  const { assetId } = req.params;
  const { userId, userName, location } = req.body;

  // Update asset status
  await db.query(`
    UPDATE assets
    SET status = 'available',
        assigned_to = NULL,
        current_location = $1
    WHERE id = $2
  `, [location, assetId]);

  // Log event
  await db.query(`
    INSERT INTO asset_events (id, asset_id, event_type, performed_by, location)
    VALUES ($1, $2, 'return', $3, $4)
  `, [generateId(), assetId, userName, location]);

  res.json({
    success: true,
    message: 'Item returned to inventory'
  });
});

Location tracking via scan data

Every scan includes geographic data. Extract it from the analytics:

async function getAssetLocationHistory(assetId) {
  const asset = await db.query(
    'SELECT qr_analytics_id FROM assets WHERE id = $1',
    [assetId]
  ).then(r => r.rows[0]);

  const response = await fetch(
    `${BASE_URL}/v1/analytics/${asset.qr_analytics_id}`,
    { headers: { 'X-API-Key': API_KEY } }
  );

  const analytics = await response.json();

  // Map scans to location timeline
  const locationHistory = analytics.scans.map(scan => ({
    timestamp: scan.scanned_at,
    city: scan.city,
    country: scan.country,
    device: scan.device_type,
    // Approximate location from IP
    coordinates: scan.coordinates || null
  }));

  return locationHistory;
}

Output:

Asset: MacBook Pro #247
Location History:
────────────────────
Jan 4, 2026 9:15 AM  │ San Francisco, US │ iOS
Jan 3, 2026 3:42 PM  │ San Francisco, US │ Android
Dec 28, 2025 11:20 AM│ Oakland, US       │ iOS
Dec 15, 2025 2:00 PM │ San Francisco, US │ Android

Find missing equipment

Search for assets that haven't been scanned recently:

async function findMissingAssets(daysSinceLastScan = 30) {
  const assets = await db.query('SELECT * FROM assets');
  const missing = [];

  for (const asset of assets.rows) {
    if (!asset.qr_analytics_id) continue;

    const response = await fetch(
      `${BASE_URL}/v1/analytics/${asset.qr_analytics_id}`,
      { headers: { 'X-API-Key': API_KEY } }
    );

    const analytics = await response.json();

    if (analytics.total_scans === 0) {
      missing.push({
        asset,
        status: 'never_scanned',
        daysSinceLastScan: null
      });
      continue;
    }

    const lastScan = new Date(analytics.scans[0].scanned_at);
    const daysSince = (Date.now() - lastScan) / (1000 * 60 * 60 * 24);

    if (daysSince > daysSinceLastScan) {
      missing.push({
        asset,
        status: 'stale',
        lastScanDate: lastScan,
        lastLocation: analytics.scans[0].city,
        daysSinceLastScan: Math.floor(daysSince)
      });
    }
  }

  return missing;
}

Output:

Missing Assets Report (>30 days since scan)
───────────────────────────────────────────
Asset                  │ Last Seen      │ Days
───────────────────────┼────────────────┼──────
Projector #12          │ Oakland        │ 45
USB-C Hub #8           │ Never scanned  │ --
Monitor Stand #23      │ San Francisco  │ 67
Webcam #5              │ Berkeley       │ 38

Maintenance tracking

Log maintenance events when assets are scanned for repairs:

app.post('/asset/:assetId/maintenance', async (req, res) => {
  const { assetId } = req.params;
  const { technicianId, technicianName, issueType, notes } = req.body;

  // Update asset status
  await db.query(`
    UPDATE assets
    SET status = 'maintenance'
    WHERE id = $1
  `, [assetId]);

  // Log maintenance event
  await db.query(`
    INSERT INTO asset_events
    (id, asset_id, event_type, performed_by, notes)
    VALUES ($1, $2, 'maintenance', $3, $4)
  `, [generateId(), assetId, technicianName, `${issueType}: ${notes}`]);

  res.json({
    success: true,
    message: 'Maintenance request logged'
  });
});

async function getMaintenanceHistory(assetId) {
  const events = await db.query(`
    SELECT * FROM asset_events
    WHERE asset_id = $1 AND event_type = 'maintenance'
    ORDER BY created_at DESC
  `, [assetId]);

  return events.rows;
}

Bulk asset registration

Import existing inventory and generate QR codes:

async function bulkRegisterAssets(csvData) {
  const results = { success: 0, failed: 0, errors: [] };

  for (const row of csvData) {
    try {
      // Create asset record
      const asset = await db.query(`
        INSERT INTO assets (id, name, category, serial_number)
        VALUES ($1, $2, $3, $4)
        RETURNING *
      `, [generateId(), row.name, row.category, row.serialNumber])
        .then(r => r.rows[0]);

      // Generate QR code
      await createAssetQR(asset);

      results.success++;
    } catch (error) {
      results.failed++;
      results.errors.push({
        row: row.name,
        error: error.message
      });
    }

    // Rate limiting
    await new Promise(resolve => setTimeout(resolve, 100));
  }

  return results;
}

// Usage with CSV
import { parse } from 'csv-parse';
import fs from 'fs';

const csvContent = fs.readFileSync('assets.csv', 'utf-8');
const records = await new Promise((resolve, reject) => {
  parse(csvContent, { columns: true }, (err, data) => {
    if (err) reject(err);
    else resolve(data);
  });
});

const results = await bulkRegisterAssets(records);
console.log(`Registered ${results.success} assets, ${results.failed} failed`);

Generate printable labels for asset tags:

async function generateLabelSheet(assets) {
  const labels = [];

  for (const asset of assets) {
    // Get or create QR code
    let qrUrl = asset.qr_code_url;
    if (!qrUrl) {
      const qr = await createAssetQR(asset);
      qrUrl = qr.qrCodeUrl;
    }

    labels.push({
      qrCodeUrl: qrUrl,
      name: asset.name,
      id: asset.id.slice(0, 8), // Short ID for label
      category: asset.category
    });
  }

  // Return data for your label printing software
  return {
    labels,
    format: 'avery_5160', // 30 labels per sheet
    count: labels.length
  };
}

Label template (for thermal printers):

┌─────────────────────────────┐
│  [QR CODE]   Asset Name     │
│              ID: ABC123     │
│              Cat: Laptop    │
└─────────────────────────────┘

Dashboard queries

Build an admin dashboard with these queries:

// Overview stats
async function getDashboardStats() {
  const stats = await db.query(`
    SELECT
      COUNT(*) as total_assets,
      COUNT(*) FILTER (WHERE status = 'available') as available,
      COUNT(*) FILTER (WHERE status = 'checked_out') as checked_out,
      COUNT(*) FILTER (WHERE status = 'maintenance') as in_maintenance
    FROM assets
  `).then(r => r.rows[0]);

  // Recent activity
  const recentEvents = await db.query(`
    SELECT ae.*, a.name as asset_name
    FROM asset_events ae
    JOIN assets a ON ae.asset_id = a.id
    ORDER BY ae.created_at DESC
    LIMIT 10
  `).then(r => r.rows);

  return { stats, recentEvents };
}

// Assets by category
async function getAssetsByCategory() {
  return db.query(`
    SELECT category, COUNT(*) as count,
           COUNT(*) FILTER (WHERE status = 'checked_out') as checked_out
    FROM assets
    GROUP BY category
    ORDER BY count DESC
  `).then(r => r.rows);
}

// Most active assets (most scans)
async function getMostActiveAssets(limit = 10) {
  const assets = await db.query('SELECT * FROM assets LIMIT 100');
  const withScans = [];

  for (const asset of assets.rows) {
    if (!asset.qr_analytics_id) continue;

    const analytics = await fetch(
      `${BASE_URL}/v1/analytics/${asset.qr_analytics_id}`,
      { headers: { 'X-API-Key': API_KEY } }
    ).then(r => r.json());

    withScans.push({
      ...asset,
      totalScans: analytics.total_scans
    });
  }

  return withScans
    .sort((a, b) => b.totalScans - a.totalScans)
    .slice(0, limit);
}

Dashboard output:

Asset Inventory Dashboard
─────────────────────────
Total: 234 │ Available: 156 │ Checked Out: 67 │ Maintenance: 11

By Category:
Laptops      ████████████████  89
Monitors     ██████████        52
Accessories  ████████          41
AV Equipment ██████            28
Other        ████              24

Recent Activity:
• MacBook #247 checked out by Sarah Chen (2 min ago)
• Monitor #89 returned to Storage Room A (15 min ago)
• Projector #12 maintenance requested (1 hour ago)

Mobile-friendly scan interface

When staff scan from their phones, show a clean action interface:

app.get('/asset/:assetId/mobile', async (req, res) => {
  const { assetId } = req.params;
  const asset = await getAsset(assetId);

  // Render mobile-friendly HTML
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <style>
        body { font-family: -apple-system, sans-serif; padding: 20px; }
        .asset-card { border: 1px solid #ddd; padding: 20px; border-radius: 8px; }
        .status { padding: 4px 8px; border-radius: 4px; font-size: 12px; }
        .status.available { background: #d4edda; color: #155724; }
        .status.checked_out { background: #fff3cd; color: #856404; }
        .action-btn { width: 100%; padding: 15px; margin: 10px 0; border: none;
                      border-radius: 8px; font-size: 16px; cursor: pointer; }
        .primary { background: #007bff; color: white; }
        .secondary { background: #6c757d; color: white; }
      </style>
    </head>
    <body>
      <div class="asset-card">
        <h2>${asset.name}</h2>
        <p>ID: ${asset.id.slice(0, 8)}</p>
        <p>Category: ${asset.category}</p>
        <span class="status ${asset.status}">${asset.status}</span>
        ${asset.assigned_to ? `<p>Assigned to: ${asset.assigned_to}</p>` : ''}
      </div>

      <div class="actions">
        ${asset.status === 'available' ?
          `<button class="action-btn primary" onclick="checkout()">
            Check Out
           </button>` :
          `<button class="action-btn primary" onclick="returnItem()">
            Return Item
           </button>`
        }
        <button class="action-btn secondary" onclick="updateLocation()">
          Update Location
        </button>
        <button class="action-btn secondary" onclick="reportIssue()">
          Report Issue
        </button>
      </div>

      <script>
        // Action handlers
        function checkout() { /* ... */ }
        function returnItem() { /* ... */ }
        function updateLocation() { /* ... */ }
        function reportIssue() { /* ... */ }
      </script>
    </body>
    </html>
  `);
});

Summary

You now have an asset tracking system that:

  • Generates unique QR codes for each piece of equipment
  • Logs check-outs, returns, and location updates
  • Tracks location history via scan geolocation
  • Identifies missing or stale assets
  • Maintains complete audit trails
  • Works from any mobile device

The key insight: every scan is data. Static labels are write-once. Dynamic QR codes build a complete history of every interaction with every asset.


Ready to track your inventory? Create your free account and generate your first asset QR codes.

Ready to get started?

Create your free account and start generating QR codes in minutes.