Build a Local Network Inventory Scanner

Build a barcode-scanning inventory app accessible from any device on your LAN.

Introduction

30 Second Summary

Ever looked at a shelf full of products and wondered how many you actually have left? Whether it's a home pantry, a garage workshop, or an office supply closet, keeping track of inventory by hand is tedious and error-prone.

In this project, you will build a local inventory management system that runs on your computer and is accessible from any device on your network. You'll scan product barcodes with your phone camera and watch items appear in a searchable dashboard in real time.

What You'll Build

Open the app on your phone, point your camera at a product barcode, and watch it instantly appear in your inventory. Scan the same barcode again and the quantity goes up. Pull up the dashboard on your laptop and see everything updating live.

By the end of this project, you'll have:

  • A phone-based barcode scanner that reads real product barcodes using your camera and adds them to your inventory with a single scan, with the quantity incrementing automatically on repeat scans.
  • A searchable, sortable dashboard showing all your inventory items with quantities, accessible from any device on your home network.
  • A complete full-stack web application backed by a SQLite database file that stores everything locally on your machine with zero cloud costs.
  • Secret Mission: Add low-stock alerts that highlight items running low and inline editing to rename products directly from the dashboard.

Are there any prerequisites?

This project assumes basic comfort with HTML, CSS, and JavaScript. You don't need prior experience with Node.js or databases. The steps walk you through everything from scratch.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Taking a moment to articulate your goal turns a follow-along tutorial into a project you own.

Set Up the Project and Generate a Local HTTPS Certificate

Your inventory scanner needs to access your phone's camera through the browser. Browsers only grant camera access on pages served over HTTPS, so you need a certificate even though this is a local project.

In this step, you'll create the project folder, install the two key dependencies, and generate a self-signed HTTPS certificate. By the end, you'll have a minimal server proving everything connects.

In this step, get ready to:
  • Create the project directory and install Express and better-sqlite3.
  • Generate a self-signed HTTPS certificate with your local network IP.
  • Build a minimal HTTPS server and confirm it responds.
Create the project directory and install dependencies
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir inventory-scanner
cd inventory-scanner
  • Initialize the project with a default package.json by running:
npm init -y

You'll see the generated package.json printed in your terminal.

  • Install the two project dependencies by running:
npm install express@5.2.1 better-sqlite3@13.0.1

What are these packages?

  • express@5.2.1 is a web framework for Node.js that handles HTTP requests and routing.
  • better-sqlite3@13.0.1 is a fast SQLite database driver that stores your inventory data in a single file on disk.
  • Confirm the install worked by running:
ls

You should see node_modules, package.json, and package-lock.json in the output.

Seeing permission errors or EACCES?

This usually means npm is trying to write to a directory it doesn't own. Make sure you created the inventory-scanner folder yourself (not with sudo) and that you're inside it before running the install.

help me fix npm permission errors in my project folder

Generate the self-signed HTTPS certificate

When your phone connects to the server over your local network, it uses your machine's IP address. The certificate needs to list that IP so the browser trusts the connection enough to grant camera access.

  • Find your machine's local IP address by running:
hostname -I

You'll see one or more IP addresses separated by spaces. Copy the first one (it looks like 192.168.x.x or 10.0.x.x).

  • Generate the certificate and private key by running this command, replacing the IP placeholder with your actual IP:
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -keyout key.pem -out cert.pem -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:[[LAN_IP="your local IP (e.g. 192.168.1.42)"]]"

What does this command do?

  • -x509 -newkey rsa:2048 creates a new 2048-bit RSA key and a self-signed certificate in one step.
  • -nodes skips encrypting the key with a passphrase, so your server can load it without a password prompt.
  • -days 365 makes the certificate valid for one year.
  • -addext "subjectAltName=..." tells browsers this certificate is valid for localhost AND your network IP. Without this, your phone would reject the connection.
  • Verify the files were created by running:
ls *.pem

You should see cert.pem and key.pem listed.

Command not found: openssl?

On most Linux distributions, openssl is pre-installed. If yours doesn't have it, install it with your package manager (e.g. sudo apt install openssl on Debian/Ubuntu).

help me install openssl on Linux

Create the minimal HTTPS server

Now you'll wire everything together into a single server.js file that loads the certificate and starts an HTTPS server.

  • Open the project in VS Code by running:
code .
  • Create a new file called server.js in the root of your inventory-scanner folder.
  • Paste the following code into server.js:
const https = require('https');
const fs = require('fs');
const path = require('path');
const express = require('express');

const app = express();
const PORT = 3000;

// Load HTTPS certificate
const options = {
  key: fs.readFileSync(path.join(__dirname, 'key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'cert.pem'))
};

// Middleware
app.use(express.json());

// Test route to confirm the server works
app.get('/', (req, res) => {
  res.send('Hello Inventory!');
});

// Start HTTPS server on all network interfaces
const server = https.createServer(options, app);
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running at https://localhost:${PORT}`);
});

What does this code do?

  • fs.readFileSync loads your certificate and key into memory so the HTTPS server can use them.
  • express.json() is middleware that parses incoming JSON request bodies. You'll need this when the frontend sends barcode data later.
  • '0.0.0.0' tells the server to listen on all network interfaces, not just localhost. This is what lets your phone reach the server by IP.
  • The / route is a temporary test. You'll replace it with real API routes in the next step.
  • Save server.js.

Before you run the server, what do you think you'll see in the terminal when it starts successfully?

  • Start the server by running:
node server.js

You should see Server running at https://localhost:3000 printed in your terminal.

  • Open your browser and go to https://localhost:3000.

What about the certificate warning?

Your browser will show a warning saying the certificate is not trusted. This is expected for self-signed certificates. Click Advanced and then Proceed (or Accept the Risk in Firefox) to continue.

You should see Hello Inventory! displayed in your browser.

Not seeing Hello Inventory?

  • If the terminal shows an error about cert.pem or key.pem not found, make sure both files are in the same folder as server.js. Run ls to confirm.
  • If you see EADDRINUSE, something else is using port 3000. Stop it or change PORT in server.js to 3001.
  • If the browser won't load the page, make sure you typed https:// (not http://).

help me debug my HTTPS server

✔️ Awesome, I've got everything!

Great. Double check you've saved server.js and stop the server with Ctrl+C before moving on.

ⓧ I'd like to double check the full code

Here is the full server.js file. Make sure yours matches:

const https = require('https');
const fs = require('fs');
const path = require('path');
const express = require('express');

const app = express();
const PORT = 3000;

// Load HTTPS certificate
const options = {
  key: fs.readFileSync(path.join(__dirname, 'key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'cert.pem'))
};

// Middleware
app.use(express.json());

// Test route to confirm the server works
app.get('/', (req, res) => {
  res.send('Hello Inventory!');
});

// Start HTTPS server on all network interfaces
const server = https.createServer(options, app);
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running at https://localhost:${PORT}`);
});

Your HTTPS server is live and responding. Next up, you'll build the inventory page so you can start adding items from any device on your network.

Build the Inventory Page with Manual Item Entry

Your server is running, but it only says "Hello Inventory!" and that is not very useful. Before adding barcode scanning, you need a visible, working inventory system.

In this step, you will connect a SQLite database to your server and build a web page that lets you add items manually. By the end, you will be typing barcode numbers on your phone and watching them appear in a database-backed list.

In this step, get ready to:
  • Create a SQLite database with an items table to store your inventory.
  • Build API routes that let you add and retrieve items.
  • Create an HTML page with a form and table that any device on your network can access.
Create the database schema and API routes

Your inventory needs a place to live. You will use better-sqlite3 to create a database file on disk and define a table that stores each item's barcode, name, quantity, and timestamp.

  • In server.js, replace the entire file contents with this code that sets up the database:
const https = require('https');
const fs = require('fs');
const path = require('path');
const express = require('express');
const Database = require('better-sqlite3');

const app = express();
const PORT = 3000;

// Load HTTPS certificate
const options = {
  key: fs.readFileSync(path.join(__dirname, 'key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'cert.pem'))
};

// Middleware
app.use(express.json());

// Database setup
const db = new Database(path.join(__dirname, 'inventory.db'));
db.pragma('journal_mode = WAL');
db.exec(`
  CREATE TABLE IF NOT EXISTS items (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    barcode TEXT UNIQUE NOT NULL,
    name TEXT NOT NULL DEFAULT 'Unknown Item',
    quantity INTEGER NOT NULL DEFAULT 1,
    created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
  )
`);

What does this code do?

The top section loads the same modules as before, plus better-sqlite3 for database access.

  • new Database('inventory.db') creates (or opens) a SQLite database file in your project folder.
  • db.pragma('journal_mode = WAL') enables Write-Ahead Logging for better performance with concurrent reads.
  • The CREATE TABLE IF NOT EXISTS statement defines your items table with five columns, only creating it if it does not already exist.
  • Now add the API routes and server startup code directly below the database setup:
// Get all items
app.get('/api/items', (req, res) => {
  const items = db.prepare('SELECT * FROM items ORDER BY created_at DESC').all();
  res.json(items);
});

// Add item
app.post('/api/items', (req, res) => {
  const { barcode, name } = req.body;
  if (!barcode) return res.status(400).json({ error: 'Barcode is required' });
  const itemName = name || 'Unknown Item';
  db.prepare('INSERT INTO items (barcode, name, quantity) VALUES (?, ?, 1)').run(barcode, itemName);
  const item = db.prepare('SELECT * FROM items WHERE barcode = ?').get(barcode);
  res.json(item);
});

// Start server
const server = https.createServer(options, app);
server.listen(PORT, '0.0.0.0', () => {
  console.log(`Server running at https://localhost:${PORT}`);
});

What do these routes do?

  • GET /api/items returns every item in the database, newest first.
  • POST /api/items accepts a JSON body with a barcode and optional name, inserts a new row, and returns the created item.
  • The ? placeholders in the SQL prevent injection attacks by separating data from commands.
  • Save server.js.
  • Before you run the server, what do you think will happen when you send a POST request to /api/items?
  • Start the server and test the API by running these commands:
node server.js
  • Open a second terminal and send a test request:
curl -k -X POST https://localhost:3000/api/items \
  -H "Content-Type: application/json" \
  -d '{"barcode": "1234567890", "name": "Test Item"}'

You should see a JSON response like {"id":1,"barcode":"1234567890","name":"Test Item","quantity":1,"created_at":"..."}.

  • Confirm the item was stored by fetching all items:
curl -k https://localhost:3000/api/items

You should see an array containing your test item.

Seeing an error when starting the server?

  • If you see Cannot find module 'better-sqlite3', make sure you ran npm install in the previous step. Run it again to be sure.
  • If you see EADDRINUSE, the server from Step 1 is still running. Press Ctrl+C in that terminal first, then try again.
  • help me debug my server startup error
  • Stop the server with Ctrl+C before continuing. You will restart it after creating the HTML page.
Create the inventory page

Now you need a web page so you can interact with your inventory from a browser instead of curl.

  • Create a public folder inside your project directory by running:
mkdir public
  • Create a new file public/index.html and add this head section with styles:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Inventory Scanner</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 16px; }
    .container { max-width: 900px; margin: 0 auto; }
    h1 { margin-bottom: 16px; color: #333; }
    .controls { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-bottom: 16px; }
    .form-row { display: flex; gap: 8px; flex-wrap: wrap; }
    .form-row input { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; min-width: 120px; }
    .form-row button { background: #16a34a; color: #fff; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; }
    table { width: 100%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
    th { background: #f8fafc; padding: 12px; text-align: left; font-size: 12px; text-transform: uppercase; color: #666; }
    td { padding: 12px; border-top: 1px solid #eee; }
  </style>
</head>
  • Directly below the </head> tag, add the body with the form and table structure:
<body>
  <div class="container">
    <h1>Inventory Scanner</h1>
    <div class="controls">
      <div class="form-row">
        <input type="text" id="barcode-input" placeholder="Barcode number">
        <input type="text" id="name-input" placeholder="Item name (optional)">
        <button id="add-btn">Add Item</button>
      </div>
    </div>
    <table>
      <thead>
        <tr>
          <th>Barcode</th><th>Name</th><th>Qty</th><th>Added</th>
        </tr>
      </thead>
      <tbody id="items-table"></tbody>
    </table>
  </div>
  <script>
  </script>
</body>
</html>

What does this page contain?

  • A text input for the barcode number and another for an optional item name.
  • An "Add Item" button that will submit the form data to your API.
  • A table with headers for Barcode, Name, Qty, and Added date. The <tbody id="items-table"> element will be filled dynamically by JavaScript.
  • Now find the empty <script> tags and add this JavaScript between them:
    let items = [];
    // Fetch all items from the API and re-render
    async function loadItems() {
      const res = await fetch('/api/items');
      items = await res.json();
      renderTable();
    }
    // Build table rows from the items array
    function renderTable() {
      const tbody = document.getElementById('items-table');
      tbody.innerHTML = items.map(item => `<tr><td>${item.barcode}</td><td>${item.name}</td><td>${item.quantity}</td><td>${new Date(item.created_at).toLocaleDateString()}</td></tr>`).join('');
    }
    // Send a new item to the server
    async function addItem() {
      const barcode = document.getElementById('barcode-input').value.trim();
      const name = document.getElementById('name-input').value.trim();
      if (!barcode) return;
      await fetch('/api/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ barcode, name: name || undefined }) });
      document.getElementById('barcode-input').value = '';
      document.getElementById('name-input').value = '';
      await loadItems();
    }
    // Wire up button click and Enter key
    document.getElementById('add-btn').addEventListener('click', addItem);
    document.getElementById('barcode-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') addItem(); });
    loadItems();

What does this script do?

  • loadItems() fetches the full item list from your API and calls renderTable() to display them.
  • renderTable() builds HTML table rows from the items array and inserts them into the <tbody>.
  • addItem() reads the form inputs, POSTs the data to your API, clears the fields, and reloads the list.
  • The event listeners let the learner click the button or press Enter to submit.
  • Save public/index.html.
  • Confirm the file was created by running:
ls public

You should see index.html listed in the output.

Don't see index.html?

  • Make sure you created the public folder first with mkdir public before saving the file inside it.
  • Check that you saved the file as public/index.html and not in the project root.
  • help me find my index.html file
Serve static files and test from your phone

The HTML file exists on disk, but your server does not know to serve it yet. You need to tell Express to serve everything in the public folder as static files.

  • In server.js, find this line:
app.use(express.json());
  • Add this line directly below it:
app.use(express.static(path.join(__dirname, 'public')));

What does express.static do?

This middleware tells Express to serve any file inside the public folder when a browser requests it. When someone visits the root URL, Express automatically looks for index.html in that folder.

  • Save server.js.
  • Find your machine's local IP address by running:
hostname -I

This prints your IP addresses separated by spaces. The first one is typically your LAN address (something like 192.168.1.x).

  • Start the server again:
node server.js
  • On your computer, open https://localhost:3000 in a browser. Accept the certificate warning.

You should see the Inventory Scanner page with a barcode input field, a name input field, and an Add Item button.

  • Now pick up your phone. Make sure it is connected to the same Wi-Fi network as your computer.
  • Open your phone's browser and navigate to https://your LAN IP:3000.
  • Accept the certificate warning on your phone.

Why does my phone show a security warning?

Your self-signed certificate is not issued by a trusted authority, so the browser warns you. This is expected for local development. Tap "Advanced" and then "Proceed" (or your browser's equivalent) to continue.

  • Before you test, what do you expect will happen when you type a barcode and tap Add Item?
  • In the barcode field, type 5901234123457.
  • In the name field, type Cereal Box.
  • Tap Add Item.

The item appears in the table below the form with its barcode, name, quantity of 1, and today's date.

Can't reach the app from your phone?

  • Make sure your phone and computer are on the same Wi-Fi network.
  • Double-check the IP address from hostname -I. Use the first address listed.
  • If your firewall blocks port 3000, temporarily allow it or disable the firewall for testing.
  • help me connect to my server from my phone

✔️ Awesome, I've got everything!

Great. Double check you have saved both server.js and public/index.html.

ⓧ I'd like to double check the full code

Your server.js should contain these sections in order. Scroll up and confirm each is present:

  • The five require statements at the top (https, fs, path, express, better-sqlite3).
  • App and PORT declarations.
  • HTTPS options loading cert and key.
  • Two app.use() middleware lines (express.json and express.static).
  • Database setup: new Database, pragma WAL, and the CREATE TABLE statement.
  • GET /api/items route.
  • POST /api/items route.
  • HTTPS server creation and listen on port 3000.

Your public/index.html should contain:

  • A <head> with CSS styles for container, controls, form-row, table, th, and td.
  • A <body> with a form (barcode input, name input, Add Item button) and a table with thead and tbody.
  • A <script> section with loadItems(), renderTable(), and addItem() functions plus event listeners.

Your inventory page is live and accessible from any device on your network. Next up, you will add barcode scanning so your phone camera can read barcodes and auto-fill the input field.

Add Barcode Scanning with Your Phone Camera

Your inventory app works and you can add items from any device on your network. But typing barcodes by hand is slow and error-prone.

The real power of this project is pointing your phone camera at a product barcode and watching it appear in your inventory instantly. This is where the HTTPS certificate you generated in Step 1 pays off. Browsers only grant camera access to pages served over HTTPS.

In this step, get ready to:
  • Add the html5-qrcode library and scanning UI to your HTML page.
  • Initialize the camera-based barcode scanner targeting your phone's rear camera.
  • Add USB barcode scanner support as an alternative input method.
Add the html5-qrcode library and scanning UI elements

The html5-qrcode library wraps the ZXing barcode decoding engine and provides a ready-made scanner UI that handles camera permissions and rendering for you.

  • In VS Code, open public/index.html.
  • Find the last CSS rule in the <style> block (the line that says td { padding: 12px; border-top: 1px solid #eee; }).
  • Add these new styles directly below it:
.scan-btn { background: #2563eb; color: #fff; border: none; padding: 10px 20px; border-radius: 6px; cursor: pointer; font-size: 16px; margin-bottom: 12px; }
.scan-btn:hover { background: #1d4ed8; }
#scanner-container { margin-bottom: 12px; }
#usb-input { position: absolute; left: -9999px; }

What do these styles do?

  • .scan-btn styles a prominent blue button that stands out from the green "Add Item" button.
  • #scanner-container adds spacing below the camera viewfinder when it's active.
  • #usb-input hides a helper input off-screen (you'll use it for USB scanner detection later).
  • Now find the opening <div class="controls"> line in the HTML body.
  • Add a scanning button and scanner container div directly after it, before the <div class="form-row"> line:
<button class="scan-btn" id="scan-btn">Start Scanning</button>
<div id="scanner-container"></div>
  • Scroll down past the closing </div> of the container div (the line just before the existing <script> tag).
  • Add a hidden input element for USB scanner support between the container's closing </div> and the scripts:
<input type="text" id="usb-input" autocomplete="off">
  • Now add the html5-qrcode library from the unpkg CDN. Place this script tag directly before your existing <script> tag:
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
  • Save public/index.html.
  • Refresh your browser at https://localhost:3000. You should see a blue "Start Scanning" button above the barcode input form.

Don't see the blue button?

  • Make sure the button HTML is inside the .controls div but before the .form-row div.
  • Check that .scan-btn in your CSS matches class="scan-btn" on the button element.
  • help me find why my Start Scanning button isn't appearing
Initialize the barcode scanner with camera configuration

The button is in place, but tapping it does nothing yet. You need JavaScript that creates a scanner instance, opens the rear camera, and pipes decoded barcodes into your form.

  • Inside the <script> tag, find the line let items = [];.
  • Add two new variables directly below it:
let scanner = null;
let scannerRunning = false;
  • Now find the closing brace of the addItem() function (the } line after await loadItems();).
  • Add the scanner click handler below it, before the document.getElementById('add-btn') line:
document.getElementById('scan-btn').addEventListener('click', () => {
  if (scannerRunning) {
    scanner.clear();
    scannerRunning = false;
    document.getElementById('scan-btn').textContent = 'Start Scanning';
    return;
  }
  scanner = new Html5QrcodeScanner('scanner-container', {
    fps: 10,
    qrbox: { width: 250, height: 150 },
    formatsToSupport: [
      Html5QrcodeSupportedFormats.EAN_13,
      Html5QrcodeSupportedFormats.UPC_A,
      Html5QrcodeSupportedFormats.CODE_128,
      Html5QrcodeSupportedFormats.EAN_8,
      Html5QrcodeSupportedFormats.UPC_E
    ],
    videoConstraints: { facingMode: 'environment' }
  });
  scanner.render((decodedText) => {
    document.getElementById('barcode-input').value = decodedText;
    scanner.clear();
    scannerRunning = false;
    document.getElementById('scan-btn').textContent = 'Start Scanning';
    addItem();
  }, (error) => {});
  scannerRunning = true;
  document.getElementById('scan-btn').textContent = 'Stop Scanning';
});

What does this scanner code do?

  • Tapping the button toggles the scanner on and off. If it's already running, scanner.clear() shuts it down.
  • Html5QrcodeScanner renders a camera viewfinder into the scanner-container div, using facingMode: 'environment' to select the rear camera on mobile devices.
  • formatsToSupport limits decoding to common product barcode types (EAN-13, UPC-A, Code 128, EAN-8, UPC-E) so the scanner doesn't waste time looking for QR codes.
  • On a successful scan, the decoded text fills the barcode input, the scanner stops, and addItem() is called to immediately save it to the database.
  • Save public/index.html.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node server.js

Before you test on your phone, what do you think will happen the first time you tap "Start Scanning"? Will the camera open immediately?

  • Open the app on your phone at https://your local IP:3000 (accept the certificate warning if prompted).
  • Tap Start Scanning.

The library first shows a "Request Camera Permissions" button inside the scanner area. Tap it, then allow camera access when your browser asks. The rear camera viewfinder appears.

  • Point your phone camera at any product barcode (a cereal box, a book, a drink bottle).
  • The barcode number auto-fills the input field and the item appears in your table immediately.

Camera not opening on your phone?

  • Make sure you're accessing the app via https:// (not http). Camera access requires HTTPS.
  • Check that you accepted the self-signed certificate warning. On Android, if you see a "connection not private" page, tap Advanced then Proceed.
  • Confirm your phone and computer are on the same Wi-Fi network.
  • help me get camera scanning working on my phone
Add USB barcode scanner support

USB barcode scanners simulate keyboard input. They type the entire barcode as rapid keystrokes (all characters within about 50ms) followed by Enter. You can detect this pattern and treat it as a scan.

  • In public/index.html, find the scanner click handler you just added.
  • Add the USB scanner detection code directly below the scanner click handler's closing });, before the document.getElementById('add-btn') line:
// USB barcode scanner support (detects rapid keystrokes)
let usbBuffer = '';
let usbTimeout = null;
document.addEventListener('keypress', (e) => {
  // Ignore if user is typing in a regular input field
  if (document.activeElement.tagName === 'INPUT' && document.activeElement.id !== 'usb-input') return;
  usbBuffer += e.key;
  clearTimeout(usbTimeout);
  // Wait 100ms - if no more keys arrive, process the buffer
  usbTimeout = setTimeout(() => {
    if (usbBuffer.length >= 6) {
      document.getElementById('barcode-input').value = usbBuffer.replace(/\n|\r/g, '');
      addItem();
    }
    usbBuffer = '';
  }, 100);
});

How does USB scanner detection work?

Every keypress adds to a buffer. After 100ms of silence, the code checks if 6 or more characters accumulated. Human typing is far slower, so only a USB scanner produces that many characters within 100ms. If the threshold is met, the buffer contents fill the barcode input and addItem() submits it automatically.

The hidden usb-input field you added earlier ensures the keypress listener still works even when no visible input has focus.

  • Save public/index.html.
  • Restart your server (Ctrl+C, then node server.js).
  • Refresh the page in your browser to confirm no console errors appear in DevTools (F12 to open DevTools, check the Console tab).

No USB scanner? No problem

The USB detection is a bonus input method. Your primary scanning flow uses the phone camera. If you have a USB barcode scanner, plug it in and scan something to see it auto-submit. If not, the camera scanner is your main workflow.

✔️ Awesome, I've got everything!

Great. Double check you've saved public/index.html and your server is running.

ⓧ I'd like to double check the full code

Your public/index.html file should now contain all the scanning code. Scroll through and confirm these sections are present in order:

  • The .scan-btn, #scanner-container, and #usb-input CSS rules at the end of the style block.
  • The Start Scanning button and scanner-container div inside .controls, before .form-row.
  • The hidden usb-input element after the container div.
  • The CDN script tag for html5-qrcode@2.3.8 before your inline script.
  • scanner and scannerRunning variables declared after let items = [];.
  • The scan-btn click handler with Html5QrcodeScanner initialization.
  • The USB keypress listener with usbBuffer and 100ms timeout.

Your inventory app now has two scanning methods: point your phone camera at any barcode, or plug in a USB scanner. Next up, you'll fix what happens when you scan the same barcode twice.

Handle Duplicate Scans with Upsert Logic

Your inventory scanner can add items from your phone, a USB scanner, or manual entry. But what happens when the same product gets scanned twice?

In this step, you'll discover a gap in your current database logic, fix it with SQL upsert, and add manual quantity controls so you can adjust stock levels on the fly.

In this step, get ready to:
  • Fix the POST route so re-scanning a barcode increments quantity instead of failing.
  • Add quantity adjustment buttons (+/-) with a PATCH endpoint.
  • Add a delete button and DELETE endpoint to remove items entirely.
Try scanning the same barcode twice

Before you change any code, try something. What do you think will happen if you enter the same barcode number a second time?

  • Make sure your server is running. If it's not, start it by running:
node server.js
  • Open your app in the browser at https://localhost:3000.
  • Type 1234567890123 in the barcode field and click Add Item.
  • Now type the exact same barcode 1234567890123 again and click Add Item.

The second submission fails. Your table still shows the item once with quantity 1. The server threw a UNIQUE constraint error because the barcode column is unique, and the code only knows how to INSERT new rows.

This is the gap. You want scanning the same barcode to increment its quantity, not crash. That's exactly what SQL upsert logic solves.

Replace the POST route with upsert logic

SQLite's INSERT ... ON CONFLICT syntax lets you specify what happens when a unique constraint is violated. Instead of throwing an error, it updates the existing row.

  • In server.js, find the existing app.post('/api/items', ...) route.
  • Replace the entire route with the following upsert version:
// Add or increment item (upsert)
app.post('/api/items', (req, res) => {
  const { barcode, name } = req.body;

  if (!barcode) {
    return res.status(400).json({ error: 'Barcode is required' });
  }

  const itemName = name || 'Unknown Item';

  const stmt = db.prepare(`
    INSERT INTO items (barcode, name, quantity)
    VALUES (?, ?, 1)
    ON CONFLICT(barcode) DO UPDATE SET
      quantity = quantity + 1,
      name = CASE WHEN items.name = 'Unknown Item' THEN excluded.name ELSE items.name END
  `);

  stmt.run(barcode, itemName);

  const item = db.prepare('SELECT * FROM items WHERE barcode = ?').get(barcode);
  res.json(item);
});

What does this code do?

The ON CONFLICT(barcode) clause catches the UNIQUE constraint violation. Instead of erroring, it runs the UPDATE logic.

The quantity = quantity + 1 increments the existing row's count. The CASE WHEN keeps the existing name unless it was set to 'Unknown Item', so a name provided later can fill in the blank.

  • Also replace the server.listen block at the bottom of server.js with this improved version that prints your network IP on startup:
// Start server
const server = https.createServer(options, app);
server.listen(PORT, '0.0.0.0', () => {
  const { networkInterfaces } = require('os');
  const nets = networkInterfaces();
  let localIP = 'localhost';

  for (const name of Object.keys(nets)) {
    for (const net of nets[name]) {
      if (net.family === 'IPv4' && !net.internal) {
        localIP = net.address;
        break;
      }
    }
  }

  console.log(`Inventory server running at:`);
  console.log(`  Local:   https://localhost:${PORT}`);
  console.log(`  Network: https://${localIP}:${PORT}`);
});

What does this code do?

This uses Node's os.networkInterfaces() to find your machine's local IPv4 address and prints it on startup. Now you can copy the network URL directly from the terminal to access the app from your phone.

  • Save server.js.
  • Stop the server with Ctrl+C and restart it by running:
node server.js

You should see the new startup message with both a Local and Network URL printed.

  • Now test the upsert. Enter the same barcode 1234567890123 three times using the form.

The table now shows that barcode once with quantity 3 (1 from before plus 2 more). No duplicates, no errors.

Still seeing an error or quantity not incrementing?

  • Make sure you replaced the entire app.post('/api/items', ...) route, not just parts of it.
  • Check that the backtick template literal around the SQL is intact. The ON CONFLICT clause needs to be inside the same db.prepare() call as the INSERT.
  • Confirm you restarted the server after saving. Code changes don't take effect until you stop and restart.

Help me debug my upsert logic

Add quantity adjustment buttons with a PATCH endpoint

Scanning increments by 1 automatically, but sometimes you need to manually adjust a count. You'll add + and - buttons next to each item, backed by a PATCH endpoint that updates the quantity in the database.

  • In server.js, add this PATCH route directly below the app.post('/api/items', ...) route you just updated:
// Update item (quantity or name)
app.patch('/api/items/:id', (req, res) => {
  const { id } = req.params;
  const { quantity, name } = req.body;

  if (quantity !== undefined) {
    if (quantity < 0) {
      return res.status(400).json({ error: 'Quantity cannot be negative' });
    }
    db.prepare('UPDATE items SET quantity = ? WHERE id = ?').run(quantity, id);
  }

  if (name !== undefined) {
    db.prepare('UPDATE items SET name = ? WHERE id = ?').run(name, id);
  }

  const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id);
  if (!item) {
    return res.status(404).json({ error: 'Item not found' });
  }
  res.json(item);
});

What does this code do?

This route accepts a JSON body with an optional quantity and optional name field. It updates whichever fields are provided, rejects negative quantities, and returns the updated item.

  • Save server.js.

Now update the frontend to add +/- buttons. You'll add new CSS, update the table rendering, and add a handler function.

  • In public/index.html, find the closing td { padding: 12px; border-top: 1px solid #eee; } rule in the <style> block. Add these styles directly below it:
    .qty-controls { display: flex; align-items: center; gap: 8px; }
    .qty-btn { width: 28px; height: 28px; border-radius: 50%; border: 1px solid #ddd; background: #fff; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; }
    .qty-btn:hover { background: #f0f0f0; }
    .delete-btn { color: #dc2626; background: none; border: none; cursor: pointer; font-size: 14px; }
    .delete-btn:hover { text-decoration: underline; }

What does this code do?

The .qty-controls class creates a flex row for the +/- buttons and quantity number. The .qty-btn styles make circular buttons. You're also adding .delete-btn styles now so they're ready for the next substep.

  • Find the table header row in the HTML:
<tr><th>Barcode</th><th>Name</th><th>Qty</th><th>Added</th></tr>
  • Replace it with this version that adds an empty column for action buttons:
<tr><th>Barcode</th><th>Name</th><th>Qty</th><th>Added</th><th></th></tr>
  • Now replace the entire renderTable function in the <script> section with this version that includes +/- buttons and a Remove button:
    function renderTable() {
      const tbody = document.getElementById('items-table');
      tbody.innerHTML = items.map(item => `
        <tr>
          <td>${item.barcode}</td>
          <td>${item.name}</td>
          <td>
            <div class="qty-controls">
              <button class="qty-btn" onclick="updateQty(${item.id}, ${item.quantity - 1})">-</button>
              <span>${item.quantity}</span>
              <button class="qty-btn" onclick="updateQty(${item.id}, ${item.quantity + 1})">+</button>
            </div>
          </td>
          <td>${new Date(item.created_at).toLocaleDateString()}</td>
          <td><button class="delete-btn" onclick="deleteItem(${item.id})">Remove</button></td>
        </tr>
      `).join('');
    }

What does this code do?

Each row now has circular - and + buttons that call updateQty() with the current quantity minus or plus 1. The Remove button calls deleteItem() which you'll wire up shortly.

  • Add the updateQty function directly after the addItem function in the script section:
    // Update quantity
    async function updateQty(id, newQty) {
      if (newQty < 0) return;
      await fetch(`/api/items/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ quantity: newQty })
      });
      await loadItems();
    }

What does this code do?

This sends a PATCH request to the server with the new quantity value. If the new quantity would go below 0, it does nothing. After updating, it reloads the full item list to reflect the change.

  • Save public/index.html.
  • Restart the server (Ctrl+C then node server.js) and refresh the browser.

You should see circular + and - buttons next to each item's quantity. Click + a few times on one item and watch the number go up.

Buttons not appearing or not working?

  • If the buttons don't appear, make sure you replaced the entire renderTable function. The old version had a simple one-line template.
  • If clicking + does nothing, check that the updateQty function is defined in the script section. Open the browser console (F12) to look for error messages.
  • Confirm you added the PATCH route to server.js and restarted the server.

Help me debug my quantity buttons

Add a DELETE endpoint and wire up the Remove button

The Remove button is already in your table rows from the previous change. Right now clicking it would fail because the server has no DELETE endpoint yet. Time to add one.

  • In server.js, add this DELETE route directly below the PATCH route you just added:
// Delete item
app.delete('/api/items/:id', (req, res) => {
  const { id } = req.params;
  const result = db.prepare('DELETE FROM items WHERE id = ?').run(id);

  if (result.changes === 0) {
    return res.status(404).json({ error: 'Item not found' });
  }
  res.json({ success: true });
});

What does this code do?

This deletes the row matching the given ID. The result.changes property tells you how many rows were affected. If it's 0, the item didn't exist and the server returns a 404.

  • Now add the deleteItem function in public/index.html, directly after the updateQty function:
    // Delete item
    async function deleteItem(id) {
      if (!confirm('Remove this item?')) return;
      await fetch(`/api/items/${id}`, { method: 'DELETE' });
      await loadItems();
    }

What does this code do?

The confirm() dialog prevents accidental deletions. If the user clicks OK, it sends a DELETE request and reloads the list.

  • Save both server.js and public/index.html.
  • Restart the server (Ctrl+C then node server.js) and refresh the browser.
  • Click Remove on one of your test items. Confirm the dialog.

The item disappears from the table immediately.

Remove button not working?

  • If clicking Remove does nothing, open the browser console (F12) and check for errors. A 'deleteItem is not defined' message means the function wasn't added to the script section.
  • If you see a 404 response, confirm the DELETE route in server.js uses app.delete (lowercase) and that you restarted the server.

Help me debug my delete functionality

Time for the final check. Do you think scanning the same barcode three times will show one item with quantity 3, or three separate items?

  • Delete all test items using the Remove button so you start fresh.
  • Enter the barcode 5901234123457 with the name Test Product and click Add Item.
  • Enter the same barcode 5901234123457 two more times (name can be blank).

Your table shows one row for that barcode with quantity 3. The upsert is working perfectly.

✔️ Awesome, I've got everything!

Great. Double check you've saved both server.js and public/index.html.

ⓧ I'd like to double check the full code

Here are both files in full. Scroll up through your editor and confirm each section is present and in order.

For server.js, confirm these sections exist in order:

  • The require statements and app/PORT setup.
  • HTTPS options loading cert and key.
  • Middleware (express.json() and express.static).
  • Database setup with CREATE TABLE.
  • GET /api/items route.
  • POST /api/items route with ON CONFLICT upsert logic.
  • PATCH /api/items/:id route.
  • DELETE /api/items/:id route.
  • Server start with network IP display.

For public/index.html, confirm:

  • CSS includes .qty-controls, .qty-btn, and .delete-btn rules.
  • Table header has an empty <th></th> at the end for the actions column.
  • The renderTable function renders +/- buttons and a Remove button per row.
  • The updateQty and deleteItem functions are defined after addItem.

Your inventory now handles real-world scanning behavior. Next up, you'll build a proper dashboard with search and sorting so you can actually manage a growing list of items.

Build the Dashboard View

Your inventory system can scan barcodes, add items, adjust quantities, and remove entries. But as your inventory grows, a flat table becomes hard to use.

In this step, you'll turn the basic table into a proper dashboard. You'll add a summary bar for at-a-glance stats, a search box for finding items instantly, and clickable column headers for sorting.

In this step, get ready to:
  • Add a summary bar showing unique item count, total quantity, and last scan time.
  • Implement a real-time search input that filters items by barcode or name.
  • Add sortable column headers that toggle between ascending and descending order.
Add a summary bar at the top of the page

A quick-glance summary helps you understand your inventory's state without scanning the entire table. You'll add three cards: unique item count, total quantity, and last scan timestamp.

  • In public/index.html, find the h1 CSS rule. Add these styles directly below it:
    .summary-bar { display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap; }
    .summary-card { background: #fff; border-radius: 8px; padding: 12px 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); flex: 1; min-width: 120px; }
    .summary-card .label { font-size: 12px; color: #666; text-transform: uppercase; }
    .summary-card .value { font-size: 24px; font-weight: bold; color: #333; }
  • Now add the summary bar HTML. Find the line <h1>Inventory Scanner</h1> in the body section and add this directly below it:
    <div class="summary-bar">
      <div class="summary-card"><div class="label">Unique Items</div><div class="value" id="total-items">0</div></div>
      <div class="summary-card"><div class="label">Total Quantity</div><div class="value" id="total-quantity">0</div></div>
      <div class="summary-card"><div class="label">Last Scan</div><div class="value" id="last-scan" style="font-size:14px;">--</div></div>
    </div>

What does this HTML do?

Each .summary-card is a flexbox child that expands to fill available space. The id attributes on the value divs let your JavaScript update the numbers dynamically.

  • Save public/index.html and refresh your browser.
  • You should see three white cards at the top of the page showing "Unique Items: 0", "Total Quantity: 0", and "Last Scan: --".

Don't see the summary cards?

Make sure the summary-bar div is placed after the <h1> tag but before the .controls div. Check that your CSS styles are inside the existing <style> block, not after it.

Help me debug my summary bar layout.

The cards are showing static defaults. Now you need JavaScript to populate them with real data from your inventory.

  • In the <script> section, find your loadItems() function. Replace it with this updated version that calls a new updateSummary() function before rendering the table:
    async function loadItems() {
      const res = await fetch('/api/items');
      items = await res.json();
      updateSummary();
      renderTable();
    }
    function updateSummary() {
      document.getElementById('total-items').textContent = items.length;
      document.getElementById('total-quantity').textContent = items.reduce((sum, i) => sum + i.quantity, 0);
      if (items.length > 0) {
        document.getElementById('last-scan').textContent = new Date(items[0].created_at).toLocaleTimeString();
      }
    }

What does this code do?

The updateSummary() function reads the items array that loadItems() just fetched and writes three values into the summary cards.

  • items.length gives the count of unique barcodes in your database.
  • .reduce() sums up all quantities across every item.
  • The last scan time comes from the first item in the array (items are sorted by created_at DESC, so the newest is always first).
  • Save public/index.html and refresh your browser.
  • If you have items in your inventory from earlier steps, the summary cards now show your actual unique item count, total quantity, and the time of your most recent scan.

Cards still showing 0?

Make sure the updateSummary(); call is placed inside loadItems() AFTER the items = await res.json(); line. If it's called before that line, the items array is still empty.

Help me debug my summary bar values.

Add a search input for filtering items

Once you have dozens of items, scrolling through the table to find one product is slow. A search box that filters in real time solves this.

  • In the CSS section of public/index.html, find the .form-row button rule. Replace it and add the new styles below it by pasting this code in place of the existing .form-row button rule:
    .form-row button { background: #16a34a; color: #fff; border: none; padding: 10px 16px; border-radius: 6px; cursor: pointer; font-size: 14px; white-space: nowrap; }
    .form-row button:hover { background: #15803d; }
    .search-row { margin-bottom: 12px; }
    .search-row input { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 14px; }
  • In the HTML body, find the closing </div> of the .controls div (the one right before the <table> tag). Add the search row between them:
    <div class="search-row">
      <input type="text" id="search-input" placeholder="Search by barcode or name...">
    </div>

Why client-side search?

Since your entire item list is already loaded into the items array, filtering in JavaScript is instant. There's no need for a server round-trip. The filter runs on every keystroke, giving you real-time results.

  • Save public/index.html and refresh your browser.
  • You should see a full-width search input between the controls section and the table, with placeholder text "Search by barcode or name...".

Search box not visible?

Make sure the .search-row div is placed AFTER the closing </div> of the controls section and BEFORE the <table> element. If it's inside the controls div, it may be hidden by the layout.

Help me position my search input correctly.

Add sortable column headers and wire up search logic

Both search filtering and column sorting modify the same renderTable() function. You'll update the table headers to be clickable, then replace renderTable() with a version that filters by the search input and sorts by the selected column.

  • In the CSS, find the existing th rule. Replace it with this updated version that adds a pointer cursor and hover effect:
    th { background: #f8fafc; padding: 12px; text-align: left; font-size: 12px; text-transform: uppercase; color: #666; cursor: pointer; user-select: none; }
    th:hover { background: #e2e8f0; }
  • In the HTML, find the table header row (<thead>). Replace the existing <tr> inside it with this version that adds data-sort attributes:
        <tr>
          <th data-sort="barcode">Barcode</th>
          <th data-sort="name">Name</th>
          <th data-sort="quantity">Qty</th>
          <th data-sort="created_at">Added</th>
          <th></th>
        </tr>

What are data-sort attributes?

The data-sort attribute stores the database column name that each header should sort by. Your JavaScript will read this value when the header is clicked and use it to reorder the items array.

  • Save and refresh. Hover over a column header and you should see your cursor change to a pointer and the background darken slightly.

The headers look clickable now, but nothing happens yet. Time to wire up the JavaScript that powers both search and sorting.

  • In the <script> section, find the line let items = [];. Add these two variables directly below it:
    let sortField = 'created_at';
    let sortDir = 'desc';
  • Now replace the entire existing renderTable() function with this version that filters by search text and sorts by the selected column:
    function renderTable() {
      const search = document.getElementById('search-input').value.toLowerCase();
      let filtered = items.filter(i => i.barcode.toLowerCase().includes(search) || i.name.toLowerCase().includes(search));
      filtered.sort((a, b) => {
        let valA = a[sortField]; let valB = b[sortField];
        if (typeof valA === 'string') valA = valA.toLowerCase();
        if (typeof valB === 'string') valB = valB.toLowerCase();
        if (valA < valB) return sortDir === 'asc' ? -1 : 1;
        if (valA > valB) return sortDir === 'asc' ? 1 : -1;
        return 0;
      });
      const tbody = document.getElementById('items-table');
      tbody.innerHTML = filtered.map(item => `
        <tr>
          <td>${item.barcode}</td>
          <td>${item.name}</td>
          <td><div class="qty-controls"><button class="qty-btn" onclick="updateQty(${item.id}, ${item.quantity - 1})">-</button><span>${item.quantity}</span><button class="qty-btn" onclick="updateQty(${item.id}, ${item.quantity + 1})">+</button></div></td>
          <td>${new Date(item.created_at).toLocaleDateString()}</td>
          <td><button class="delete-btn" onclick="deleteItem(${item.id})">Remove</button></td>
        </tr>
      `).join('');
    }

What does the new renderTable do?

This function now runs in three stages. First, it reads the search input and filters the items array to only include matches against barcode or name.

Second, it sorts the filtered results using sortField and sortDir. The comparison handles both strings and numbers correctly. Third, it renders the sorted, filtered items into the table.

  • Finally, add the event listeners that connect the search input and sortable headers to renderTable(). Find the line document.getElementById('add-btn').addEventListener('click', addItem); and add this code directly above it:
    document.querySelectorAll('th[data-sort]').forEach(th => {
      th.addEventListener('click', () => {
        const field = th.dataset.sort;
        if (sortField === field) { sortDir = sortDir === 'asc' ? 'desc' : 'asc'; } else { sortField = field; sortDir = 'asc'; }
        renderTable();
      });
    });
    document.getElementById('search-input').addEventListener('input', renderTable);

How does the sort toggle work?

When you click a column header, the listener checks if you're already sorting by that column. If so, it flips the direction (ascending becomes descending). If you click a different column, it starts sorting by that column in ascending order.

The search listener fires on every keystroke (input event), so filtering happens as you type with no button press needed.

Before you test, what do you think will happen when you type a partial barcode number into the search box?

  • Save public/index.html and refresh your browser.
  • Type a partial barcode number or item name into the search box. The table filters in real time, showing only matching rows.
  • Click the **Barcode** column header. The table sorts alphabetically by barcode. Click it again and the order reverses.
  • Click the **Qty** column header. Items sort by quantity (lowest first on first click, highest first on second click).

Search or sorting not working?

Open your browser's developer console (press F12) and check for JavaScript errors. A common mistake is forgetting to add the let sortField = 'created_at'; and let sortDir = 'desc'; variables at the top of the script.

Also verify that the search input has id="search-input" in the HTML. The JavaScript references this exact ID.

Help me debug my search and sort features.

✔️ Awesome, I've got everything!

Great. Double check you've saved public/index.html and that search, sort, and summary bar all work on refresh.

ⓧ I'd like to double check the full code

Your public/index.html is over 30 lines, so here is a checklist of what should be present. Scroll through your file and confirm each section exists in order:

  • CSS: .summary-bar, .summary-card, .summary-card .label, and .summary-card .value rules present after h1.
  • CSS: .form-row button includes white-space: nowrap; and has a :hover rule below it.
  • CSS: .search-row and .search-row input rules present.
  • CSS: th rule includes cursor: pointer; user-select: none; and has a th:hover rule below it.
  • HTML: .summary-bar div with three .summary-card children appears after <h1>.
  • HTML: .search-row div with id="search-input" appears between the controls div and the table.
  • HTML: Each <th> has a data-sort attribute matching a database column.
  • JS: sortField and sortDir variables declared after let items = [];.
  • JS: loadItems() calls updateSummary() before renderTable().
  • JS: updateSummary() function updates total-items, total-quantity, and last-scan.
  • JS: renderTable() filters items by search text and sorts by sortField/sortDir.
  • JS: Click listeners on th[data-sort] elements and an input listener on search-input.

Your inventory app now has a proper dashboard. You can scan items from your phone, then switch to your laptop to search, sort, and manage everything at a glance. Next up, you'll tackle the Secret Mission to add low-stock alerts and inline editing.

Secret mission

Add Low-Stock Alerts and Inline Editing

Your inventory scanner works, but a real system warns you when stock is running low. It also lets you rename products without deleting and re-adding them. Can you add configurable low-stock alerts and double-click inline name editing?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • A SQLite database file (inventory.db) storing your scanned items. This costs nothing and grows with your inventory.
  • A self-signed HTTPS certificate (cert.pem and key.pem). Valid for 365 days.
  • A Node.js server process. It only runs while you have it started in the terminal.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep scanning items.

  • Leave the server running with node server.js in your terminal.
  • Your inventory data persists in inventory.db between server restarts.
  • Access the app from any device on your network at the HTTPS URL printed in the terminal.

Pause - I'll come back to this later

Shut down the running server to free up your terminal. Your data stays safe in the database file.

  • In the terminal where the server is running, press Ctrl+C to stop the process.
  • Your inventory data remains in inventory.db inside your project folder.
  • Start the server again any time by running node server.js from the project directory.

Delete - I don't want to use this again

Remove all project files, including the database, certificate, and installed packages.

  • Stop the server first by pressing Ctrl+C in the terminal where it is running.
  • Delete the entire project folder by running:
rm -rf ~/Desktop/inventory-scanner
  • This removes server.js, public/index.html, inventory.db, the certificate files, and all installed node_modules.
  • Confirm the folder is gone by running:
ls ~/Desktop/inventory-scanner

You should see a "No such file or directory" message confirming the deletion.

Nice Work!

Nice Work!

Nice work! You just built a full-stack inventory management system from scratch and made it accessible from any device on your local network.

You've learned how to:

  • Build a full-stack web application with Node.js, Express, and a SQLite database, including CRUD operations and upsert logic for handling duplicate barcodes.
  • Integrate browser-based barcode scanning using the html5-qrcode library, with phone camera access enabled by a self-signed HTTPS certificate over your LAN.
  • Build a searchable, sortable dashboard with real-time filtering, quantity controls, and a summary bar accessible from any device on your network.
  • Secret Mission: Added low-stock alerts with configurable thresholds and inline product name editing.

Ready to quiz yourself?