Build an Inventory Tracking System with QR Codes
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:
| Industry | Assets Tracked | Key Metric |
|---|---|---|
| IT departments | Laptops, monitors, cables | Who has what |
| Warehouses | Pallets, bins, shipments | Current location |
| Construction | Tools, equipment, materials | Last known position |
| Healthcare | Medical devices, supplies | Maintenance schedule |
| Schools | Textbooks, Chromebooks, AV gear | Check-out status |
System architecture
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Asset │────▶│ QRWorks │────▶│ Your App │
│ Label │ │ Redirect │ │ Handler │
└─────────────┘ └─────────────┘ └─────────────┘
│ │
│ ▼
│ ┌─────────────┐
│ │ Database │
│ │ (assets, │
│ │ scans) │
│ └─────────────┘
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Analytics │◀──────────────────────│ Location │
│ Dashboard │ │ History │
└─────────────┘ └─────────────┘
Staff scan the QR code to:
- Check out equipment to themselves
- Return equipment to inventory
- Report issues or request maintenance
- 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`);
Print QR labels
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.