DEV Community

Talekh Hajiyev
Talekh Hajiyev

Posted on Fully Autonomous

Putting a whole packing list inside a QR code (no server, no app)

Most "smart" box labels are a QR code that points to a URL. Scan it and a web page shows you the contents. That works until the service shuts down, the phone has no signal in a basement storage unit, or the mover's phone blocks unknown links.

For our Moving Planner we went the other way: the QR code is the contents list. No URL, no server, no app. Any phone camera decodes it to plain text.

Here is what that took, in a single offline HTML file.

1. Plain text, UTF-8, byte mode

A QR code in byte mode stores raw bytes. We build the label text like this:

BOX 12 — KITCHEN
FRAGILE · OPEN FIRST

1. Plates — 12
2. Mugs — 6
3. Kettle
Enter fullscreen mode Exit fullscreen mode

and encode it as UTF-8. That matters because the planner ships in 9 languages: a Cyrillic letter takes 2 bytes, a Chinese character 3, so a Russian or Chinese list fills the code much faster than an English one. The library we use (qrcode-generator by Kazuhiko Arase, MIT) lets you pass a UTF-8 byte string directly.

On iPhone and Android the native camera shows the decoded text with "Copy" / "Search" options. Nothing is opened, nothing is fetched.

2. The real limit is print size, not capacity

The spec allows up to 2,953 bytes in a version 40 code with low error correction. That's plenty of text — but a version 40 code is 177 × 177 modules. Printed on a 99 × 68 mm sticker, each module would be well under half a millimetre, and a phone held at arm's length in a dim hallway won't read it.

So the rule we settled on: every module must be at least 0.30 mm wide. For each label size we know how many millimetres the QR gets, so:

function qrFor(text, sizeMm) {
  for (const ec of ['M', 'L']) {          // prefer more error correction
    const qr = qrcode(0, ec);             // 0 = smallest version that fits
    qr.addData(utf8(text), 'Byte');
    try { qr.make(); } catch (e) { continue; }   // too long for this EC level
    const modules = qr.getModuleCount() + 8;     // + quiet zone
    if (sizeMm / modules >= 0.30) return qr;
  }
  return null;   // doesn't fit → show "list too long for this label size"
}
Enter fullscreen mode Exit fullscreen mode

Medium error correction first (survives tape over a corner), drop to Low only if that's what it takes to fit. If neither fits, the label says so instead of printing an unreadable code — you either split the box or pick the larger label size.

3. Things you must not put in the code

A label is public: movers, neighbours, the building lobby. So items can be marked "Hide on label" — they stay in your private list and search, but are excluded from both the printed lines and the QR text.

4. Knowing which labels are stale

Once a label is printed, the list can change. We store a signature of everything that ends up on the label (language, QR text, room name and colour, flags, visible items, packed date). If the current signature differs from the printed one, the box drops back into a "Not printed yet" filter, so you reprint only what changed.

5. Offline single file

Everything — app, 9 translations, QR library, print layouts for A4/Letter and Avery L7165/5164 sheets — is one HTML file. Data lives in IndexedDB with a localStorage fallback, and backup/restore is a JSON file with the photos inlined.


If you want to see the labels: there's a free live demo (no saving or printing in the demo). The full planner is $29 on Gumroad. I'm the founder, so I'm biased — but I'd love feedback on the QR sizing rule, especially from anyone who has printed codes for warehouses.

Top comments (0)